import * as React from 'react'; import { observer } from 'mobx-react'; import { MSKTab, MSKTabs } from '../../../shared/components/MSKTabs/MSKTabs'; import { action, computed, makeObservable, observable } from 'mobx'; import { ResultsViewPageStore } from '../ResultsViewPageStore'; import _ from 'lodash'; import OncoprintDropdownCount from './OncoprintDropdownCount'; import CustomDropdown from 'shared/components/oncoprint/controls/CustomDropdown'; import { deriveDisplayTextFromGenericAssayType, filterGenericAssayEntitiesByGenes, makeGenericAssayOption, } from 'shared/lib/GenericAssayUtils/GenericAssayCommonUtils'; import GenericAssaySelection, { GenericAssayTrackInfo, } from 'pages/studyView/addChartButton/genericAssaySelection/GenericAssaySelection'; import { IOncoprintControlsHandlers, IOncoprintControlsState, } from 'shared/components/oncoprint/controls/OncoprintControls'; import autobind from 'autobind-decorator'; import { getTextWidth, remoteData } from 'cbioportal-frontend-commons'; import { clinicalAttributeIsINCOMPARISONGROUP, SpecialAttribute, clinicalAttributeIsPROFILEDIN, } from 'shared/cache/ClinicalDataCache'; import { ExtendedClinicalAttribute } from '../ResultsViewPageStoreUtils'; import { ClinicalAttribute } from 'cbioportal-ts-api-client'; import { GENERIC_ASSAY_CONFIG } from 'shared/lib/GenericAssayUtils/GenericAssayConfig'; import { ClinicalTrackConfig } from 'shared/components/oncoprint/Oncoprint'; import SaveClinicalTracksButton from 'pages/resultsView/oncoprint/SaveClinicalTracksButton'; import { toggleIncluded } from 'shared/lib/ArrayUtils'; import AddChartByType from 'pages/studyView/addChartButton/addChartByType/AddChartByType'; import LoadingIndicator from 'shared/components/loadingIndicator/LoadingIndicator'; import { MakeMobxView } from 'shared/components/MobxView'; export interface IAddTrackProps { store: ResultsViewPageStore; heatmapMenu: JSX.Element | null; handlers: IOncoprintControlsHandlers; state: IOncoprintControlsState; selectedGenericAssayEntitiesGroupedByGenericAssayTypeFromUrl?: { [genericAssayType: string]: string[]; }; oncoprinterMode?: boolean; } enum Tab { CLINICAL = 'Clinical', HEATMAP = 'Heatmap', GROUPS = 'Groups', CUSTOM_CHARTS = 'Custom Charts', } export const MIN_DROPDOWN_WIDTH = 500; export const CONTAINER_PADDING_WIDTH = 20; export const TAB_PADDING_WIDTH = 14; export const COUNT_PADDING_WIDTH = 17; @observer export default class TracksMenu extends React.Component { @observable tabId: Tab | string = Tab.CLINICAL; constructor(props: IAddTrackProps) { super(props); makeObservable(this); } @action.bound private updateTabId(newId: Tab) { this.tabId = newId; // update profile if clicked Generic Assay tab // This will update generic assay profile correctly // Because if isGenericAssayDataComplete and profilesByGenericAssayType are falsy, tabs will not be shown if (!Object.values(Tab).includes(newId)) { if ( this.isGenericAssayDataComplete && this.profilesByGenericAssayType && !_.isEmpty(this.profilesByGenericAssayType[newId]) && this.props.handlers.onSelectGenericAssayProfile ) { this.props.handlers.onSelectGenericAssayProfile( this.profilesByGenericAssayType[newId][0].molecularProfileId ); } } } // make sure data from generic assay endpoints fetched @computed get isGenericAssayDataComplete() { return ( this.props.store && this.props.store.genericAssayProfiles.isComplete ); } @computed get showGenericAssayTabs() { if (this.isGenericAssayDataComplete) { const isSingleStudy = _.chain(this.props.store.genericAssayProfiles.result!) .map(profile => profile.studyId) .uniq() .size() .value() == 1; // disable for multiple studies query if (!_.isEmpty(this.profilesByGenericAssayType) && isSingleStudy) { return true; } } else { return false; } } @computed get profilesByGenericAssayType() { if (this.isGenericAssayDataComplete) { return _.groupBy( this.props.store.genericAssayProfiles.result, profile => profile.genericAssayType ); } else { return {}; } } @autobind private getSelectedClinicalAttributes(): ClinicalTrackConfig[] { let attr = this.props.state.selectedClinicalAttributeSpecInits; return attr ? _.values(attr) : []; } @action.bound private onGenericAssaySubmit(info: GenericAssayTrackInfo[]) { this.props.handlers.onClickAddGenericAssays && this.props.handlers.onClickAddGenericAssays(info); } readonly clinicalTrackOptions = remoteData({ await: () => [ this.props.store.clinicalAttributes, this.clinicalAttributeIdToAvailableFrequency, this.props.store.clinicalAttributes_customCharts, ], invoke: () => { const uniqueAttributes = _.uniqBy( this.props.store.clinicalAttributes.result!, a => a.clinicalAttributeId ); const availableFrequency = this .clinicalAttributeIdToAvailableFrequency.result!; const sortedAttributes = { clinical: [] as ExtendedClinicalAttribute[], groups: [] as ExtendedClinicalAttribute[], customCharts: [] as ExtendedClinicalAttribute[], }; const customChartClinicalAttributeIds = _.keyBy( this.props.store.clinicalAttributes_customCharts.result!, a => a.clinicalAttributeId ); for (const attr of uniqueAttributes) { if (clinicalAttributeIsINCOMPARISONGROUP(attr)) { sortedAttributes.groups.push(attr); } else if ( attr.clinicalAttributeId in customChartClinicalAttributeIds ) { sortedAttributes.customCharts.push(attr); } else { sortedAttributes.clinical.push(attr); } } sortedAttributes.clinical = _.sortBy( sortedAttributes.clinical, [ (x: ClinicalAttribute) => { if ( x.clinicalAttributeId === SpecialAttribute.StudyOfOrigin ) { return 0; } else if ( x.clinicalAttributeId === SpecialAttribute.MutationSpectrum ) { return 1; } else if (clinicalAttributeIsPROFILEDIN(x)) { return 2; } else { return 3; } }, (x: ClinicalAttribute) => { let freq = availableFrequency[x.clinicalAttributeId]; if (freq === undefined) { freq = 0; } return -freq; }, (x: ClinicalAttribute) => -x.priority, (x: ClinicalAttribute) => x.displayName.toLowerCase(), ] ); sortedAttributes.groups = _.sortBy( sortedAttributes.groups, x => x.displayName.toLowerCase() ); sortedAttributes.customCharts = _.sortBy( sortedAttributes.customCharts, x => x.displayName.toLowerCase() ); return Promise.resolve( _.mapValues(sortedAttributes, attrs => { return attrs.map(attr => ({ label: attr.displayName, key: attr.clinicalAttributeId, selected: // set false by default // manipulate this case by case false, })); }) ); }, }); readonly clinicalAttributeIdToAvailableFrequency = remoteData({ await: () => [ this.props.store.clinicalAttributeIdToAvailableSampleCount, this.props.store.samples, ], invoke: () => { const numSamples = this.props.store.samples.result!.length; return Promise.resolve( _.mapValues( this.props.store.clinicalAttributeIdToAvailableSampleCount .result!, count => (100 * count) / numSamples ) ); }, }); @computed get clinicalTracksMenu() { // TODO: put onFocus handler on CheckedSelect when possible // TODO: pass unmodified string array as value prop when possible // TODO: remove labelKey specification, leave to default prop, when possible if ( this.props.store && this.props.state.selectedClinicalAttributeSpecInits && this.props.handlers.onChangeSelectedClinicalTracks ) { return ( <> {this.props.oncoprinterMode} {this.props.state.isSessionServiceEnabled && ( )}
{this.addClinicalTracksMenu.component}
); } else { return null; } } @action.bound private addAll(clinicalAttributeIds: string[]) { this.props.handlers.onChangeSelectedClinicalTracks!( _.union( this.getSelectedClinicalAttributes(), clinicalAttributeIds.map(id => new ClinicalTrackConfig(id)) ) ); } @action.bound private clear(clinicalAttributeIds: string[]) { this.props.handlers.onChangeSelectedClinicalTracks!( _.differenceBy( this.getSelectedClinicalAttributes(), clinicalAttributeIds.map(id => new ClinicalTrackConfig(id)), 'stableId' ) ); } @action.bound private toggleClinicalTrack(clinicalAttributeId: string) { const toggled = toggleIncluded( new ClinicalTrackConfig(clinicalAttributeId), this.getSelectedClinicalAttributes(), track => track.stableId === clinicalAttributeId ); this.props.handlers.onChangeSelectedClinicalTracks!(toggled); } readonly addClinicalTracksMenu = MakeMobxView({ await: () => [this.trackOptionsByType], render: () => ( ), renderPending: () => , showLastRenderWhenPending: true, }); readonly addGroupTracksMenu = MakeMobxView({ await: () => [this.trackOptionsByType], render: () => ( <> {this.props.state.isSessionServiceEnabled && ( )} ), renderPending: () => , showLastRenderWhenPending: true, }); readonly addChartTracksMenu = MakeMobxView({ await: () => [this.trackOptionsByType], render: () => ( <> {this.props.state.isSessionServiceEnabled && ( )} ), renderPending: () => , showLastRenderWhenPending: true, }); readonly trackOptionsByType = remoteData({ await: () => [this.clinicalTrackOptions], invoke: () => { return Promise.resolve( _.mapValues(this.clinicalTrackOptions.result, options => { return options.map(option => ({ ...option, selected: option.key in _.keyBy( this.getSelectedClinicalAttributes().map( a => a.stableId ) ), })); }) ); }, }); @computed private get genericAssayTabs() { let tabs = []; if (this.isGenericAssayDataComplete && this.showGenericAssayTabs) { const genericAssayEntitiesGroupedByGenericAssayType = this.props .store.genericAssayEntitiesGroupedByGenericAssayType.result; // create one tab for each generic assay type tabs = _.map(this.profilesByGenericAssayType, (profiles, type) => { const profileOptions = _.map(profiles, profile => { return { value: profile.molecularProfileId, label: profile.name, }; }); // bring gene related options to the front let entities = []; const filteredEntities = GENERIC_ASSAY_CONFIG .genericAssayConfigByType[type]?.globalConfig ?.geneRelatedGenericAssayType ? filterGenericAssayEntitiesByGenes( genericAssayEntitiesGroupedByGenericAssayType[type], this.props.store.hugoGeneSymbols ) : []; entities = [ ...filteredEntities, ..._.difference( genericAssayEntitiesGroupedByGenericAssayType[type], filteredEntities ), ]; const entityOptions = _.map(entities, makeGenericAssayOption); const linkText = (
{deriveDisplayTextFromGenericAssayType(type)}
); return ( ); }); return tabs; } else { return null; } } @computed get clinicalTabText() { if ( this.clinicalTrackOptions.result && !_.isEmpty(this.clinicalTrackOptions.result.clinical) ) { return (
{Tab.CLINICAL}
); } else { return Tab.CLINICAL; } } @computed get heatmapTabText() { if (!_.isEmpty(this.props.store.heatmapMolecularProfiles.result)) { return (
{Tab.HEATMAP}
); } else { return Tab.HEATMAP; } } @computed get groupTabText() { if ( this.trackOptionsByType.result && !_.isEmpty(this.trackOptionsByType.result.groups) ) { return (
{Tab.GROUPS}
); } else { return Tab.GROUPS; } } @computed get customChartTabText() { if ( this.trackOptionsByType.result && !_.isEmpty(this.trackOptionsByType.result.customCharts) ) { return (
{Tab.CUSTOM_CHARTS}
); } else { return Tab.CUSTOM_CHARTS; } } private getTextPixel(text: string, fontSize: string) { // This is a very specified function to calculate the text length in Add Tracks dropdown const FRONT_FAMILY = 'Helvetica Neue'; return Math.floor(getTextWidth(text, FRONT_FAMILY, fontSize)); } @computed get dropdownWidth() { let width = 2 * CONTAINER_PADDING_WIDTH; const HEADER_FONT_SIZE = '14px'; const COUNT_FONT_SIZE = '11px'; if ( this.clinicalTrackOptions.result && !_.isEmpty(this.clinicalTrackOptions.result.clinical) ) { const textWidth = this.getTextPixel(Tab.CLINICAL, HEADER_FONT_SIZE) + TAB_PADDING_WIDTH; const countTextWidth = this.getTextPixel( this.clinicalTrackOptions.result.clinical.length.toString(), COUNT_FONT_SIZE ) + COUNT_PADDING_WIDTH; width += textWidth + countTextWidth; } if ( this.props.heatmapMenu && !_.isEmpty(this.props.store.heatmapMolecularProfiles.result) ) { const textWidth = this.getTextPixel(Tab.HEATMAP, HEADER_FONT_SIZE) + TAB_PADDING_WIDTH; const countTextWidth = this.getTextPixel( this.props.store.heatmapMolecularProfiles.result!.length.toString(), COUNT_FONT_SIZE ) + COUNT_PADDING_WIDTH; width += textWidth + countTextWidth; } if (this.showGenericAssayTabs) { // showGenericAssayTabs is true means we have more than one generic assay tabs are showing // and we know this.profilesByGenericAssayType is loaded completely const genericAssayText = this.genericAssayTabs!.map(tab => deriveDisplayTextFromGenericAssayType(tab.key as string) ).join(); const genericAssayTabsCount = this.genericAssayTabs!.length; const genericAssayCountTextWidth = _.reduce( this.profilesByGenericAssayType, (width, profiles) => { return (width += this.getTextPixel( profiles.length.toString(), COUNT_FONT_SIZE ) + COUNT_PADDING_WIDTH); }, 0 ); const genericAssayTabsWidth = this.getTextPixel(genericAssayText, HEADER_FONT_SIZE) + genericAssayTabsCount * TAB_PADDING_WIDTH + genericAssayCountTextWidth; width += genericAssayTabsWidth; } return Math.max(width, MIN_DROPDOWN_WIDTH); } render() { return (
{this.clinicalTracksMenu} {this.props.heatmapMenu} {this.addGroupTracksMenu.component} {this.addChartTracksMenu.component} {this.genericAssayTabs}
); } }