diff --git a/src/pages/patientView/PatientViewPage.tsx b/src/pages/patientView/PatientViewPage.tsx index 575385709b4..4a1533177f7 100644 --- a/src/pages/patientView/PatientViewPage.tsx +++ b/src/pages/patientView/PatientViewPage.tsx @@ -162,6 +162,7 @@ export class PatientViewPageInner extends React.Component< this.patientViewPageStore = new PatientViewPageStore( this.props.appStore, + this.urlWrapper, this.urlWrapper.query.studyId!, this.urlWrapper.query.caseId!, this.urlWrapper.query.sampleId, diff --git a/src/pages/patientView/PatientViewPageTabs.tsx b/src/pages/patientView/PatientViewPageTabs.tsx index f8f29d0496e..f24ab8ac062 100644 --- a/src/pages/patientView/PatientViewPageTabs.tsx +++ b/src/pages/patientView/PatientViewPageTabs.tsx @@ -37,6 +37,8 @@ import { HelpWidget } from 'shared/components/HelpWidget/HelpWidget'; import MutationTableWrapper from './mutation/MutationTableWrapper'; import { PatientViewPageInner } from 'pages/patientView/PatientViewPage'; import { Else, If } from 'react-if'; +import PlotsTab from 'shared/components/plots/PlotsTab'; +import { PatientViewPlotsTabWrapper } from './PatientViewPlotsTabWrapper'; export enum PatientViewPageTabs { Summary = 'summary', @@ -49,6 +51,7 @@ export enum PatientViewPageTabs { TrialMatchTab = 'trialMatchTab', MutationalSignatures = 'mutationalSignatures', PathwayMapper = 'pathways', + Plots = 'plots', } export const PatientViewResourceTabPrefix = 'openResource_'; @@ -488,6 +491,15 @@ export function tabs( ); + tabs.push( + + + + ); + tabs.push( = observer(function({ store, urlWrapper }) { + const cohortSelector = () => ( + + ); + + const customSamplePointComponent = (sampleId: string, mouseEvents: any) => ( + + ); + + return ( + + ); +}); diff --git a/src/pages/patientView/PatientViewUrlWrapper.ts b/src/pages/patientView/PatientViewUrlWrapper.ts index 0942ed9e977..3c240c0a037 100644 --- a/src/pages/patientView/PatientViewUrlWrapper.ts +++ b/src/pages/patientView/PatientViewUrlWrapper.ts @@ -22,6 +22,49 @@ export type PatientViewUrlQuery = { showOnlySelectedMutationsInTable?: string; }; + plots_horz_selection: PlotsSelectionParam; + plots_vert_selection: PlotsSelectionParam; + plots_coloring_selection: PlotsColoringParam; + geneset_list: any; + generic_assay_groups: any; +}; + +export type PlotsSelectionParam = { + selectedGeneOption?: string; + selectedGenesetOption?: string; + selectedGenericAssayOption?: string; + dataType?: string; + selectedDataSourceOption?: string; + mutationCountBy?: string; + structuralVariantCountBy?: string; + logScale?: string; +}; + +const PlotsSelectionParamProps: Required = { + selectedGeneOption: '', + selectedGenesetOption: '', + selectedGenericAssayOption: '', + dataType: '', + selectedDataSourceOption: '', + mutationCountBy: '', + structuralVariantCountBy: '', + logScale: '', +}; + +export type PlotsColoringParam = { + selectedOption?: string; + logScale?: string; + colorByMutationType?: string; + colorByCopyNumber?: string; + colorBySv?: string; +}; + +const PlotsColoringParamProps: Required = { + selectedOption: '', + logScale: '', + colorByMutationType: '', + colorByCopyNumber: '', + colorBySv: '', }; export default class PatientViewUrlWrapper extends URLWrapper< @@ -49,6 +92,20 @@ export default class PatientViewUrlWrapper extends URLWrapper< showOnlySelectedMutationsInTable: '', }, }, + plots_horz_selection: { + isSessionProp: false, + nestedObjectProps: PlotsSelectionParamProps, + }, + plots_vert_selection: { + isSessionProp: false, + nestedObjectProps: PlotsSelectionParamProps, + }, + plots_coloring_selection: { + isSessionProp: false, + nestedObjectProps: PlotsColoringParamProps, + }, + geneset_list: { isSessionProp: true }, + generic_assay_groups: { isSessionProp: false }, }); makeObservable(this); } diff --git a/src/pages/patientView/clinicalInformation/PatientViewPageStore.spec.ts b/src/pages/patientView/clinicalInformation/PatientViewPageStore.spec.ts index 29dcc460281..894096cd006 100644 --- a/src/pages/patientView/clinicalInformation/PatientViewPageStore.spec.ts +++ b/src/pages/patientView/clinicalInformation/PatientViewPageStore.spec.ts @@ -7,12 +7,19 @@ import { } from './PatientViewPageStore'; import { assert } from 'chai'; import { AppStore } from '../../../AppStore'; +import PatientViewUrlWrapper from '../PatientViewUrlWrapper'; describe('PatientViewPageStore', () => { let store: PatientViewPageStore; + let urlWrapper: PatientViewUrlWrapper; beforeAll(() => { - store = new PatientViewPageStore(new AppStore(), 'someId', ''); + store = new PatientViewPageStore( + new AppStore(), + urlWrapper, + 'someId', + '' + ); }); it('if there are pdf items in response and their name starts with a given patientId, return collection, otherwise returns empty array', () => { diff --git a/src/pages/patientView/clinicalInformation/PatientViewPageStore.ts b/src/pages/patientView/clinicalInformation/PatientViewPageStore.ts index 38831a2afd9..d1d91d03d82 100644 --- a/src/pages/patientView/clinicalInformation/PatientViewPageStore.ts +++ b/src/pages/patientView/clinicalInformation/PatientViewPageStore.ts @@ -107,10 +107,13 @@ import { tumorTypeResolver, evaluatePutativeDriverInfoWithHotspots, evaluatePutativeDriverInfo, + IDataQueryFilter, + generateDataQueryFilter, } from 'shared/lib/StoreUtils'; import { computeGenePanelInformation, CoverageInformation, + getCoverageInformation, } from 'shared/lib/GenePanelUtils'; import { fetchCivicGenes, @@ -172,6 +175,7 @@ import { TMB_H_THRESHOLD, AlterationTypeConstants, DataTypeConstants, + REQUEST_ARG_ENUM, } from 'shared/constants'; import { OTHER_BIOMARKER_HUGO_SYMBOL, @@ -186,6 +190,7 @@ import { import { getGenericAssayMetaPropertyOrDefault, getGenericAssayCategoryFromName, + fetchGenericAssayMetaByMolecularProfileIdsGroupByMolecularProfileId, } from 'shared/lib/GenericAssayUtils/GenericAssayCommonUtils'; import { GenericAssayTypeConstants } from 'shared/lib/GenericAssayUtils/GenericAssayConfig'; @@ -201,6 +206,8 @@ import { buildNamespaceColumnConfig } from 'shared/components/namespaceColumns/n import { SiteError } from 'shared/model/appMisc'; import { AnnotatedExtendedAlteration } from 'shared/model/AnnotatedExtendedAlteration'; import { CustomDriverNumericGeneMolecularData } from 'shared/model/CustomDriverNumericGeneMolecularData'; +import PatientViewUrlWrapper from '../PatientViewUrlWrapper'; +import { PatientViewPlotsStore } from './PatientViewPlotsStore'; type PageMode = 'patient' | 'sample'; type ResourceId = string; @@ -323,6 +330,7 @@ function transformClinicalInformationToStoreShape( export class PatientViewPageStore { constructor( private appStore: AppStore, + private urlWrapper: PatientViewUrlWrapper, studyId: string, patientId: string, sampleId: string = '', @@ -340,8 +348,16 @@ export class PatientViewPageStore { this._sampleId = sampleId; this.studyId = studyId; + + this.patientViewPlotsStore = new PatientViewPlotsStore( + appStore, + urlWrapper, + this + ); } + public patientViewPlotsStore: PatientViewPlotsStore; + public internalClient: CBioPortalAPIInternal; @observable public activeLocus: string | undefined; diff --git a/src/pages/patientView/clinicalInformation/PatientViewPlotsStore.ts b/src/pages/patientView/clinicalInformation/PatientViewPlotsStore.ts new file mode 100644 index 00000000000..106b4a05e7b --- /dev/null +++ b/src/pages/patientView/clinicalInformation/PatientViewPlotsStore.ts @@ -0,0 +1,1165 @@ +import _ from 'lodash'; +import { + ClinicalData, + ClinicalDataMultiStudyFilter, + GenePanelData, + MolecularProfile, + Mutation, + MutationFilter, + NumericGeneMolecularData, + Sample, + GenericAssayMeta, + MolecularDataFilter, + Gene, + Geneset, + GenePanelDataMultipleStudyFilter, + StructuralVariant, + StudyViewFilter, +} from 'cbioportal-ts-api-client'; +import { getClient } from '../../../shared/api/cbioportalClientInstance'; +import { computed, observable, action, makeObservable } from 'mobx'; +import { remoteData } from 'cbioportal-frontend-commons'; +import { + fetchClinicalData, + filterAndAnnotateMolecularData, + filterAndAnnotateMutations, + IDataQueryFilter, + generateDataQueryFilter, +} from 'shared/lib/StoreUtils'; +import { + CoverageInformation, + getCoverageInformation, +} from 'shared/lib/GenePanelUtils'; +import { AppStore } from '../../../AppStore'; +import { getFilteredMolecularProfilesByAlterationType } from 'pages/studyView/StudyViewUtils'; +import { + AlterationTypeConstants, + DataTypeConstants, + REQUEST_ARG_ENUM, +} from 'shared/constants'; +import { fetchGenericAssayMetaByMolecularProfileIdsGroupByMolecularProfileId } from 'shared/lib/GenericAssayUtils/GenericAssayCommonUtils'; +import { StructuralVariantFilter } from 'cbioportal-ts-api-client'; +import { CustomDriverNumericGeneMolecularData } from 'shared/model/CustomDriverNumericGeneMolecularData'; +import { + ExtendedClinicalAttribute, + fetchPatients, + getExtendsClinicalAttributesFromCustomData, + parseGenericAssayGroups, +} from 'pages/resultsView/ResultsViewPageStoreUtils'; +import { getSuffixOfMolecularProfile } from 'shared/lib/molecularProfileUtils'; +import { + buildDriverAnnotationSettings, + DriverAnnotationSettings, +} from 'shared/alterationFiltering/AnnotationFilteringSettings'; +import PatientViewUrlWrapper from '../PatientViewUrlWrapper'; +import MobxPromiseCache from 'shared/lib/MobxPromiseCache'; +import { parseSamplesSpecifications } from 'pages/resultsView/ResultsViewPageHelpers'; +import { SamplesSpecificationElement } from 'pages/studyView/StudyViewPageStore'; +import { AnnotatedMutation } from 'shared/model/AnnotatedMutation'; +import ClinicalDataCache from 'shared/cache/ClinicalDataCache'; +import GenesetMolecularDataCache from 'shared/cache/GenesetMolecularDataCache'; +import GenericAssayMolecularDataCache from 'shared/cache/GenericAssayMolecularDataCache'; +import GenesetCache from 'shared/cache/GenesetCache'; +import ComplexKeyMap from 'shared/lib/complexKeyDataStructures/ComplexKeyMap'; +import sessionServiceClient from '../../../shared/api/sessionServiceInstance'; +import { PatientViewPageStore } from './PatientViewPageStore'; +import { CohortOptions } from 'shared/components/plots/CohortSelector'; + +export enum SampleListCategoryType { + 'w_mut' = 'w_mut', + 'w_cna' = 'w_cna', + 'w_mut_cna' = 'w_mut_cna', +} + +export const SampleListCategoryTypeToFullId = { + [SampleListCategoryType.w_mut]: 'all_cases_with_mutation_data', + [SampleListCategoryType.w_cna]: 'all_cases_with_cna_data', + [SampleListCategoryType.w_mut_cna]: 'all_cases_with_mutation_and_cna_data', +}; + +export class PatientViewPlotsStore { + constructor( + private appStore: AppStore, + private urlWrapper: PatientViewUrlWrapper, + protected patientViewPageStore: PatientViewPageStore + ) { + makeObservable(this); + } + + @observable cohortSelection: CohortOptions = CohortOptions.WholeStudy; + + @observable + driverAnnotationSettings: DriverAnnotationSettings = buildDriverAnnotationSettings( + () => false + ); + + readonly allGenes = remoteData({ + invoke: () => { + return getClient().getAllGenesUsingGET({ + projection: 'SUMMARY', + }); + }, + }); + + readonly allHugoGeneSymbols = remoteData({ + await: () => [this.allGenes], + invoke: () => { + // build reference gene map + return Promise.resolve( + this.allGenes.result!.map(g => g.hugoGeneSymbol) + ); + }, + default: [], + }); + + readonly entrezGeneIdToGeneAll = remoteData<{ + [entrezGeneId: string]: Gene; + }>({ + await: () => [this.allGenes], + invoke: () => { + // build reference gene map + return Promise.resolve( + _.keyBy(this.allGenes.result!, g => g.entrezGeneId) + ); + }, + }); + + @action.bound + public handleCohortChange(cohort: { value: CohortOptions; label: string }) { + this.cohortSelection = cohort.value; + } + + public readonly sampleSetByKey = remoteData({ + await: () => [this.allSamplesInStudy], + invoke: () => { + return Promise.resolve( + _.keyBy( + this.allSamplesInStudy.result!, + sample => sample.uniqueSampleKey + ) + ); + }, + }); + + readonly filteredSamplesByDetailedCancerType = remoteData<{ + [cancerType: string]: Sample[]; + }>({ + await: () => [ + this.allSamplesInStudy, + this.clinicalDataForAllSamplesInStudy, + ], + invoke: () => { + let groupedSamples = this.groupSamplesByCancerType( + this.clinicalDataForAllSamplesInStudy.result, + this.allSamplesInStudy.result!, + 'CANCER_TYPE' + ); + if (_.size(groupedSamples) === 1) { + groupedSamples = this.groupSamplesByCancerType( + this.clinicalDataForAllSamplesInStudy.result, + this.allSamplesInStudy.result!, + 'CANCER_TYPE_DETAILED' + ); + } + return Promise.resolve(groupedSamples); + }, + }); + + readonly highlightedCancerTypes = remoteData({ + await: () => [this.patientViewPageStore.clinicalDataForSamples], + invoke: () => { + const highlightedCancerTypes = _( + this.patientViewPageStore.clinicalDataForSamples.result + ) + .filter(d => d.clinicalAttributeId === 'CANCER_TYPE') + .map(d => d.value) + .value(); + return Promise.resolve(highlightedCancerTypes); + }, + default: [], + }); + + readonly samplesWithSameCancerTypeAsHighlighted = remoteData({ + await: () => [ + this.allSamplesInStudy, + this.clinicalDataForAllSamplesInStudy, + this.patientViewPageStore.clinicalDataForSamples, + ], + invoke: () => { + let groupedSamples = this.groupSamplesByCancerType( + this.clinicalDataForAllSamplesInStudy.result, + this.allSamplesInStudy.result!, + 'CANCER_TYPE' + ); + const highlightedCancerTypes = _( + this.patientViewPageStore.clinicalDataForSamples.result + ) + .filter(d => d.clinicalAttributeId === 'CANCER_TYPE') + .map(d => d.value) + .value(); + if (highlightedCancerTypes.length > 0) { + return Promise.resolve( + _.flatMap(highlightedCancerTypes, t => groupedSamples[t]) + ); + } + return Promise.resolve([]); + }, + }); + + readonly highlightedDetailedCancerTypes = remoteData({ + await: () => [this.patientViewPageStore.clinicalDataForSamples], + invoke: () => { + const highlightedCancerTypes = _( + this.patientViewPageStore.clinicalDataForSamples.result + ) + .filter(d => d.clinicalAttributeId === 'CANCER_TYPE_DETAILED') + .map(d => d.value) + .value(); + return Promise.resolve(highlightedCancerTypes); + }, + default: [], + }); + + readonly samplesWithSameCancerTypeDetailedAsHighlighted = remoteData< + Sample[] + >({ + await: () => [ + this.allSamplesInStudy, + this.clinicalDataForAllSamplesInStudy, + this.patientViewPageStore.clinicalDataForSamples, + ], + invoke: () => { + let groupedSamples = this.groupSamplesByCancerType( + this.clinicalDataForAllSamplesInStudy.result, + this.allSamplesInStudy.result!, + 'CANCER_TYPE_DETAILED' + ); + const highlightedCancerTypes = _( + this.patientViewPageStore.clinicalDataForSamples.result + ) + .filter(d => d.clinicalAttributeId === 'CANCER_TYPE_DETAILED') + .map(d => d.value) + .value(); + if (highlightedCancerTypes.length > 0) { + return Promise.resolve( + _.flatMap(highlightedCancerTypes, t => groupedSamples[t]) + ); + } + return Promise.resolve([]); + }, + }); + + public groupSamplesByCancerType( + clinicalDataForSamples: ClinicalData[], + samples: Sample[], + cancerTypeLevel: 'CANCER_TYPE' | 'CANCER_TYPE_DETAILED' + ) { + // first generate map of sampleId to it's cancer type + const sampleKeyToCancerTypeClinicalDataMap = _.reduce( + clinicalDataForSamples, + (memo, clinicalData: ClinicalData) => { + if (clinicalData.clinicalAttributeId === cancerTypeLevel) { + memo[clinicalData.uniqueSampleKey] = clinicalData.value; + } + + // if we were told CANCER_TYPE and we find CANCER_TYPE_DETAILED, then fall back on it. if we encounter + // a CANCER_TYPE later, it will override this. + if (cancerTypeLevel === 'CANCER_TYPE') { + if ( + !memo[clinicalData.uniqueSampleKey] && + clinicalData.clinicalAttributeId === + 'CANCER_TYPE_DETAILED' + ) { + memo[clinicalData.uniqueSampleKey] = clinicalData.value; + } + } + + return memo; + }, + {} as { [uniqueSampleId: string]: string } + ); + + // now group samples by cancer type + let samplesGroupedByCancerType = _.reduce( + samples, + (memo: { [cancerType: string]: Sample[] }, sample: Sample) => { + // if it appears in map, then we have a cancer type + if ( + sample.uniqueSampleKey in + sampleKeyToCancerTypeClinicalDataMap + ) { + memo[ + sampleKeyToCancerTypeClinicalDataMap[ + sample.uniqueSampleKey + ] + ] = + memo[ + sampleKeyToCancerTypeClinicalDataMap[ + sample.uniqueSampleKey + ] + ] || []; + memo[ + sampleKeyToCancerTypeClinicalDataMap[ + sample.uniqueSampleKey + ] + ].push(sample); + } else { + // TODO: we need to fall back to study cancer type + } + return memo; + }, + {} as { [cancerType: string]: Sample[] } + ); + + return samplesGroupedByCancerType; + // + } + + readonly molecularProfileIdSuffixToMolecularProfiles = remoteData<{ + [molecularProfileIdSuffix: string]: MolecularProfile[]; + }>( + { + await: () => [this.patientViewPageStore.molecularProfilesInStudy], + invoke: () => { + return Promise.resolve( + _.groupBy( + this.patientViewPageStore.molecularProfilesInStudy + .result, + molecularProfile => + getSuffixOfMolecularProfile(molecularProfile) + ) + ); + }, + }, + {} + ); + + readonly clinicalAttributes = remoteData({ + await: () => [], + invoke: async () => { + return _.uniqBy( + await getClient().fetchClinicalAttributesUsingPOST({ + studyIds: [this.patientViewPageStore.studyId], + }), + clinicalAttribute => + `${clinicalAttribute.patientAttribute}-${clinicalAttribute.clinicalAttributeId}` + ); + }, + default: [], + onError: () => {}, + }); + + readonly genesetCache = new GenesetCache(); + + @computed get genesetIds() { + return this.urlWrapper.query.geneset_list && + this.urlWrapper.query.geneset_list.trim().length + ? this.urlWrapper.query.geneset_list.trim().split(/\s+/) + : []; + } + + readonly genesets = remoteData({ + invoke: () => { + if (this.genesetIds && this.genesetIds.length > 0) { + return this.patientViewPageStore.internalClient.fetchGenesetsUsingPOST( + { + genesetIds: this.genesetIds.slice(), + } + ); + } else { + return Promise.resolve([]); + } + }, + onResult: (genesets: Geneset[]) => { + this.genesetCache.addData(genesets); + }, + }); + + readonly genericAssayEntitiesGroupedByProfileId = remoteData<{ + [profileId: string]: GenericAssayMeta[]; + }>({ + await: () => [this.genericAssayProfiles], + invoke: async () => { + return await fetchGenericAssayMetaByMolecularProfileIdsGroupByMolecularProfileId( + this.genericAssayProfiles.result + ); + }, + }); + + readonly genericAssayProfiles = remoteData({ + await: () => [this.patientViewPageStore.molecularProfilesInStudy], + invoke: () => { + return Promise.resolve( + this.patientViewPageStore.molecularProfilesInStudy.result.filter( + profile => + profile.molecularAlterationType === + AlterationTypeConstants.GENERIC_ASSAY + ) + ); + }, + default: [], + }); + + readonly sampleMap = remoteData({ + await: () => [this.selectedCohortSamples], + invoke: () => { + return Promise.resolve( + ComplexKeyMap.from(this.selectedCohortSamples.result!, s => ({ + studyId: s.studyId, + sampleId: s.sampleId, + })) + ); + }, + }); + + readonly customAttributes = remoteData({ + await: () => [this.sampleMap], + invoke: async () => { + let ret: ExtendedClinicalAttribute[] = []; + if (this.appStore.isLoggedIn) { + try { + //Add custom data from user profile + const customChartSessions = await sessionServiceClient.getCustomDataForStudies( + [this.patientViewPageStore.studyId] + ); + + ret = getExtendsClinicalAttributesFromCustomData( + customChartSessions, + this.sampleMap.result! + ); + } catch (e) {} + } + return ret; + }, + }); + + readonly queriedPhysicalStudyIds = remoteData({ + // await: () => [this.queriedPhysicalStudies], + invoke: () => { + return Promise.resolve([this.patientViewPageStore.studyId]); + }, + }); + + readonly studyToMutationMolecularProfile = remoteData<{ + [studyId: string]: MolecularProfile; + }>( + { + await: () => [this.mutationProfiles], + invoke: () => { + return Promise.resolve( + _.keyBy( + this.mutationProfiles.result, + (profile: MolecularProfile) => profile.studyId + ) + ); + }, + }, + {} + ); + + readonly mutationProfiles = remoteData({ + await: () => [this.patientViewPageStore.studyIdToMolecularProfiles], + invoke: () => { + return Promise.resolve( + getFilteredMolecularProfilesByAlterationType( + this.patientViewPageStore.studyIdToMolecularProfiles.result, + AlterationTypeConstants.MUTATION_EXTENDED + ) + ); + }, + onError: () => {}, + default: [], + }); + + readonly studyToMolecularProfileDiscreteCna = remoteData<{ + [studyId: string]: MolecularProfile; + }>( + { + await: () => [this.patientViewPageStore.molecularProfilesInStudy], + invoke: async () => { + const ret: { [studyId: string]: MolecularProfile } = {}; + for (const molecularProfile of this.patientViewPageStore + .molecularProfilesInStudy.result) { + if ( + molecularProfile.datatype === + DataTypeConstants.DISCRETE && + molecularProfile.molecularAlterationType === + AlterationTypeConstants.COPY_NUMBER_ALTERATION + ) { + ret[molecularProfile.studyId] = molecularProfile; + } + } + return ret; + }, + }, + {} + ); + + readonly patientKeyToFilteredSamples = remoteData({ + await: () => [this.allSamplesInStudy], + invoke: () => { + return Promise.resolve( + _.groupBy( + this.allSamplesInStudy.result!, + sample => sample.uniquePatientKey + ) + ); + }, + }); + + readonly studyToStructuralVariantMolecularProfile = remoteData<{ + [studyId: string]: MolecularProfile; + }>( + { + await: () => [this.structuralVariantProfiles], + invoke: () => { + return Promise.resolve( + _.keyBy( + this.structuralVariantProfiles.result, + (profile: MolecularProfile) => profile.studyId + ) + ); + }, + }, + {} + ); + + readonly structuralVariantProfiles = remoteData({ + await: () => [this.patientViewPageStore.studyIdToMolecularProfiles], + invoke: () => { + // TODO: Cleanup once fusions are removed from database + return Promise.resolve( + getFilteredMolecularProfilesByAlterationType( + this.patientViewPageStore.studyIdToMolecularProfiles.result, + AlterationTypeConstants.STRUCTURAL_VARIANT, + [DataTypeConstants.FUSION, DataTypeConstants.SV] + ) + ); + }, + onError: () => {}, + default: [], + }); + + readonly allPatientsInStudy = remoteData({ + await: () => [this.allSamplesInStudy], + invoke: () => fetchPatients(this.allSamplesInStudy.result!), + default: [], + }); + + readonly selectedCohortPatients = remoteData({ + await: () => [this.selectedCohortSamples], + invoke: () => fetchPatients(this.selectedCohortSamples.result!), + default: [], + }); + + readonly genePanelDataForAllProfiles = remoteData({ + // fetch all gene panel data for profiles + // We do it this way - fetch all data for profiles, then filter based on samples - + // because + // (1) this means sending less data as parameters + // (2) this means the requests can be cached on the server based on the molecular profile id + // (3) We can initiate the gene panel data call before the samples call completes, thus + // putting more response waiting time in parallel + await: () => [this.patientViewPageStore.molecularProfilesInStudy], + invoke: () => + getClient().fetchGenePanelDataInMultipleMolecularProfilesUsingPOST({ + genePanelDataMultipleStudyFilter: { + molecularProfileIds: this.patientViewPageStore.molecularProfilesInStudy.result.map( + p => p.molecularProfileId + ), + } as GenePanelDataMultipleStudyFilter, + }), + }); + + @computed get selectedGenericAssayEntitiesGroupByMolecularProfileId() { + return parseGenericAssayGroups( + this.urlWrapper.query.generic_assay_groups || '' + ); + } + + public annotatedCnaCache = new MobxPromiseCache< + { entrezGeneId: number }, + CustomDriverNumericGeneMolecularData[] + >(q => ({ + await: () => + this.numericGeneMolecularDataCache.await( + [ + this.studyToMolecularProfileDiscreteCna, + this.patientViewPageStore.getDiscreteCNAPutativeDriverInfo, + this.entrezGeneIdToGeneAll, + ], + studyToMolecularProfileDiscrete => { + return _.values(studyToMolecularProfileDiscrete).map(p => ({ + entrezGeneId: q.entrezGeneId, + molecularProfileId: p.molecularProfileId, + })); + } + ), + invoke: () => { + const cnaData = _.flatten( + this.numericGeneMolecularDataCache + .getAll( + _.values( + this.studyToMolecularProfileDiscreteCna.result! + ).map(p => ({ + entrezGeneId: q.entrezGeneId, + molecularProfileId: p.molecularProfileId, + })) + ) + .map(p => p.result!) + ) as CustomDriverNumericGeneMolecularData[]; + const filteredAndAnnotatedReport = filterAndAnnotateMolecularData( + cnaData, + this.patientViewPageStore.getDiscreteCNAPutativeDriverInfo + .result!, + this.entrezGeneIdToGeneAll.result! + ); + const data = filteredAndAnnotatedReport.data.concat( + filteredAndAnnotatedReport.vus + ); + + return Promise.resolve( + filteredAndAnnotatedReport.data.concat( + filteredAndAnnotatedReport.vus + ) + ); + }, + })); + + readonly selectedCohortSamples = remoteData({ + await: () => [ + this.allSamplesInStudy, + this.samplesWithSameCancerTypeAsHighlighted, + this.samplesWithSameCancerTypeDetailedAsHighlighted, + ], + invoke: async () => { + if (this.cohortSelection === CohortOptions.CancerType) { + return this.samplesWithSameCancerTypeAsHighlighted.result!; + } else if ( + this.cohortSelection === CohortOptions.CancerTypeDetailed + ) { + return this.samplesWithSameCancerTypeDetailedAsHighlighted + .result!; + } else { + return this.allSamplesInStudy.result!; + } + }, + }); + + readonly selectedCohortSampleMap = remoteData({ + await: () => [ + this.filteredSampleKeyToSample, + this.samplesWithSameCancerTypeAsHighlightedKeyToSample, + this.samplesWithSameCancerTypeDetailedAsHighlightedKeyToSample, + ], + invoke: async () => { + if (this.cohortSelection === CohortOptions.CancerType) { + return this.samplesWithSameCancerTypeAsHighlightedKeyToSample + .result!; + } else if ( + this.cohortSelection === CohortOptions.CancerTypeDetailed + ) { + return this + .samplesWithSameCancerTypeDetailedAsHighlightedKeyToSample + .result!; + } else { + return this.filteredSampleKeyToSample.result!; + } + }, + }); + + public numericGeneMolecularDataCache = new MobxPromiseCache< + { entrezGeneId: number; molecularProfileId: string }, + NumericGeneMolecularData[] + >(q => ({ + await: () => [ + this._numericGeneMolecularDataCache.get(q), + this.selectedCohortSampleMap, + ], + invoke: () => { + const data = this._numericGeneMolecularDataCache.get(q).result!; + return Promise.resolve( + data.filter( + d => + d.uniqueSampleKey in + this.selectedCohortSampleMap.result! + ) + ); + }, + })); + + private _numericGeneMolecularDataCache = new MobxPromiseCache< + { entrezGeneId: number; molecularProfileId: string }, + NumericGeneMolecularData[] + >(q => ({ + await: () => [this.molecularProfileIdToDataQueryFilter], + invoke: () => { + const dqf = this.molecularProfileIdToDataQueryFilter.result![ + q.molecularProfileId + ]; + // it's possible that sampleIds is empty for a given profile + const hasSampleSpec = + dqf && + ((dqf.sampleIds && dqf.sampleIds.length) || dqf.sampleListId); + if (hasSampleSpec) { + return getClient().fetchAllMolecularDataInMolecularProfileUsingPOST( + { + molecularProfileId: q.molecularProfileId, + molecularDataFilter: { + entrezGeneIds: [q.entrezGeneId], + ...dqf, + } as MolecularDataFilter, + } + ); + } else { + return Promise.resolve([]); + } + }, + })); + + readonly molecularProfileIdToDataQueryFilter = remoteData<{ + [molecularProfileId: string]: IDataQueryFilter; + }>({ + await: () => [ + this.patientViewPageStore.molecularProfilesInStudy, + this.studyToDataQueryFilter, + ], + invoke: () => { + const ret: { [molecularProfileId: string]: IDataQueryFilter } = {}; + for (const molecularProfile of this.patientViewPageStore + .molecularProfilesInStudy.result!) { + ret[ + molecularProfile.molecularProfileId + ] = this.studyToDataQueryFilter.result![ + molecularProfile.studyId + ]; + } + return Promise.resolve(ret); + }, + default: {}, + }); + + readonly studyToDataQueryFilter = remoteData<{ + [studyId: string]: IDataQueryFilter; + }>( + { + await: () => [ + this.studyToCustomSampleList, + this.studyToSampleListId, + ], + invoke: () => { + const studies = [this.patientViewPageStore.studyId]; + const ret: { [studyId: string]: IDataQueryFilter } = {}; + for (const studyId of studies) { + ret[studyId] = generateDataQueryFilter( + this.studyToSampleListId.result![studyId], + this.studyToCustomSampleList.result![studyId] + ); + } + return Promise.resolve(ret); + }, + }, + {} + ); + + readonly studyToCustomSampleList = remoteData<{ + [studyId: string]: string[]; + }>( + { + await: () => [this.samplesSpecification], + invoke: () => { + const ret: { + [studyId: string]: string[]; + } = {}; + for (const sampleSpec of this.samplesSpecification.result!) { + if (sampleSpec.sampleId) { + // add sample id to study + ret[sampleSpec.studyId] = ret[sampleSpec.studyId] || []; + ret[sampleSpec.studyId].push(sampleSpec.sampleId); + } + } + return Promise.resolve(ret); + }, + }, + {} + ); + + readonly samplesSpecification = remoteData({ + invoke: async () => { + // is this a sample list category query? + // if YES, we need to derive the sample lists by: + // 1. looking up all sample lists in selected studies + // 2. using those with matching category + if (!this.sampleListCategory) { + return this.samplesSpecificationParams; + } else { + let samplesSpecifications = []; + samplesSpecifications = this.samplesSpecificationParams; + // get unique study ids to reduce the API requests + const uniqueStudyIds = _.chain(samplesSpecifications) + .map(specification => specification.studyId) + .uniq() + .value(); + const allSampleLists = await Promise.all( + uniqueStudyIds.map(studyId => { + return getClient().getAllSampleListsInStudyUsingGET({ + studyId: studyId, + projection: REQUEST_ARG_ENUM.PROJECTION_SUMMARY, + }); + }) + ); + + const category = + SampleListCategoryTypeToFullId[this.sampleListCategory!]; + const specs = allSampleLists.reduce( + ( + aggregator: SamplesSpecificationElement[], + sampleLists + ) => { + //find the sample list matching the selected category using the map from shortname to full category name :( + const matchingList = _.find( + sampleLists, + list => list.category === category + ); + if (matchingList) { + aggregator.push({ + studyId: matchingList.studyId, + sampleListId: matchingList.sampleListId, + sampleId: undefined, + } as SamplesSpecificationElement); + } + return aggregator; + }, + [] + ); + + return specs; + } + }, + }); + + @computed get sampleListCategory(): SampleListCategoryType | undefined { + if ( + [ + SampleListCategoryType.w_mut, + SampleListCategoryType.w_cna, + SampleListCategoryType.w_mut_cna, + ].includes((this.patientViewPageStore.studyId + '_all') as any) + ) { + return (this.patientViewPageStore.studyId + + '_all') as SampleListCategoryType; + } else { + return undefined; + } + } + + @computed get samplesSpecificationParams() { + return parseSamplesSpecifications( + _.map(this.allSamplesInStudy.result, sample => { + return sample.studyId + ':' + sample.sampleId; + }).join('+'), + undefined, + this.patientViewPageStore.studyId + '_all', + [this.patientViewPageStore.studyId] + ); + } + + readonly studyToSampleListId = remoteData<{ [studyId: string]: string }>({ + await: () => [this.samplesSpecification], + invoke: async () => { + return this.samplesSpecification.result!.reduce((map, next) => { + if (next.sampleListId) { + map[next.studyId] = next.sampleListId; + } + return map; + }, {} as { [studyId: string]: string }); + }, + }); + + readonly filteredSampleKeyToSample = remoteData({ + await: () => [this.allSamplesInStudy], + invoke: () => + Promise.resolve( + _.keyBy(this.allSamplesInStudy.result!, s => s.uniqueSampleKey) + ), + }); + + readonly samplesWithSameCancerTypeAsHighlightedKeyToSample = remoteData({ + await: () => [this.samplesWithSameCancerTypeAsHighlighted], + invoke: () => + Promise.resolve( + _.keyBy( + this.samplesWithSameCancerTypeAsHighlighted.result!, + s => s.uniqueSampleKey + ) + ), + }); + + readonly samplesWithSameCancerTypeDetailedAsHighlightedKeyToSample = remoteData( + { + await: () => [this.samplesWithSameCancerTypeDetailedAsHighlighted], + invoke: () => + Promise.resolve( + _.keyBy( + this.samplesWithSameCancerTypeDetailedAsHighlighted + .result!, + s => s.uniqueSampleKey + ) + ), + } + ); + + public annotatedMutationCache = new MobxPromiseCache< + { entrezGeneId: number }, + AnnotatedMutation[] + >(q => ({ + await: () => [ + this.mutationCache.get(q), + this.patientViewPageStore.getMutationPutativeDriverInfo, + this.entrezGeneIdToGeneAll, + ], + invoke: () => { + const filteredAndAnnotatedReport = filterAndAnnotateMutations( + this.mutationCache.get(q).result!, + this.patientViewPageStore.getMutationPutativeDriverInfo.result!, + this.entrezGeneIdToGeneAll.result! + ); + const data = filteredAndAnnotatedReport.data + .concat(filteredAndAnnotatedReport.vus) + .concat(filteredAndAnnotatedReport.germline); + + return Promise.resolve(data); + }, + })); + + public mutationCache = new MobxPromiseCache< + { entrezGeneId: number }, + Mutation[] + >(q => ({ + await: () => [ + this.studyToMutationMolecularProfile, + this.studyToDataQueryFilter, + ], + invoke: async () => { + return _.flatten( + await Promise.all( + Object.keys( + this.studyToMutationMolecularProfile.result! + ).map(studyId => { + const molecularProfileId = this + .studyToMutationMolecularProfile.result![studyId] + .molecularProfileId; + const dataQueryFilter = this.studyToDataQueryFilter + .result![studyId]; + + if ( + !dataQueryFilter || + (_.isEmpty(dataQueryFilter.sampleIds) && + !dataQueryFilter.sampleListId) + ) { + return Promise.resolve([]); + } + + if (molecularProfileId) { + return getClient().fetchMutationsInMolecularProfileUsingPOST( + { + molecularProfileId, + mutationFilter: { + entrezGeneIds: [q.entrezGeneId], + ...dataQueryFilter, + } as MutationFilter, + projection: + REQUEST_ARG_ENUM.PROJECTION_DETAILED, + } + ); + } else { + return Promise.resolve([]); + } + }) + ) + ); + }, + })); + + public structuralVariantCache = new MobxPromiseCache< + { entrezGeneId: number }, + StructuralVariant[] + >(q => ({ + await: () => [ + this.studyToStructuralVariantMolecularProfile, + this.studyToDataQueryFilter, + ], + invoke: async () => { + const studyIdToProfileMap = this + .studyToStructuralVariantMolecularProfile.result!; + + if (_.isEmpty(studyIdToProfileMap)) { + return Promise.resolve([]); + } + + const filters = this.allSamplesInStudy.result.reduce( + (memo, sample: Sample) => { + if (sample.studyId in studyIdToProfileMap) { + memo.push({ + molecularProfileId: + studyIdToProfileMap[sample.studyId] + .molecularProfileId, + sampleId: sample.sampleId, + }); + } + return memo; + }, + [] as StructuralVariantFilter['sampleMolecularIdentifiers'] + ); + + if (_.isEmpty(filters)) { + return []; + } else { + return this.patientViewPageStore.internalClient.fetchStructuralVariantsUsingPOST( + { + structuralVariantFilter: { + entrezGeneIds: [q.entrezGeneId], + sampleMolecularIdentifiers: filters, + } as StructuralVariantFilter, + } + ); + } + }, + })); + + readonly genesetMolecularDataCache = remoteData({ + await: () => [this.molecularProfileIdToDataQueryFilter], + invoke: () => + Promise.resolve( + new GenesetMolecularDataCache( + this.molecularProfileIdToDataQueryFilter.result! + ) + ), + }); + + readonly genericAssayMolecularDataCache = remoteData({ + await: () => [this.molecularProfileIdToDataQueryFilter], + invoke: () => + Promise.resolve( + new GenericAssayMolecularDataCache( + this.molecularProfileIdToDataQueryFilter.result! + ) + ), + }); + + readonly allSamplesInStudy = remoteData({ + await: () => [this.clinicalAttributes], + invoke: () => { + let studyViewFilter: StudyViewFilter = {} as any; + studyViewFilter.studyIds = [this.patientViewPageStore.studyId]; + + return this.patientViewPageStore.internalClient.fetchFilteredSamplesUsingPOST( + { + studyViewFilter: studyViewFilter, + } + ); + }, + default: [], + }); + + readonly clinicalDataForAllSamplesInStudy = remoteData( + { + await: () => [this.allSamplesInStudy], + invoke: () => { + const identifiers = this.allSamplesInStudy.result.map( + (sample: Sample) => ({ + entityId: sample.sampleId, + studyId: sample.studyId, + }) + ); + const clinicalDataMultiStudyFilter = { + identifiers, + } as ClinicalDataMultiStudyFilter; + return fetchClinicalData(clinicalDataMultiStudyFilter); + }, + }, + [] + ); + + readonly coverageInformationForAllSamples = remoteData( + { + await: () => [ + this.genePanelDataForAllProfiles, + this.sampleSetByKey, + this.allPatientsInStudy, + this.plotsSelectedGenes, + ], + invoke: () => + getCoverageInformation( + this.genePanelDataForAllProfiles.result!, + this.sampleSetByKey.result!, + this.allPatientsInStudy.result!, + this.plotsSelectedGenes.result! + ), + } + ); + + readonly plotsSelectedGenes = remoteData({ + invoke: () => { + let entrezIds: string[] = []; + // gene selected in horz axis + if ( + this.urlWrapper.query.plots_horz_selection?.selectedGeneOption + ) { + entrezIds.push( + this.urlWrapper.query.plots_horz_selection + .selectedGeneOption + ); + } + // gene selected in vert axis + if ( + this.urlWrapper.query.plots_vert_selection?.selectedGeneOption + ) { + entrezIds.push( + this.urlWrapper.query.plots_vert_selection + .selectedGeneOption + ); + } + // gene selected in color menu + if ( + this.urlWrapper.query.plots_coloring_selection + ?.selectedOption && + this.urlWrapper.query.plots_coloring_selection.selectedOption.match( + '^[0-9]*' + ) + ) { + // extract entrezGeneId from plot coloring selection string + let selectedColoringGene = this.urlWrapper.query.plots_coloring_selection.selectedOption.match( + '^[0-9]*' + )![0]; + entrezIds.push(selectedColoringGene); + } + if (entrezIds.length > 0) { + return getClient().fetchGenesUsingPOST({ + geneIdType: 'ENTREZ_GENE_ID', + geneIds: entrezIds, + }); + } + return Promise.resolve([]); + }, + }); + + readonly filteredPatientKeyToPatient = remoteData({ + await: () => [this.allPatientsInStudy], + invoke: () => + Promise.resolve( + _.keyBy(this.allPatientsInStudy.result, p => p.uniquePatientKey) + ), + }); + + public clinicalDataCache = new ClinicalDataCache( + this.selectedCohortSamples, + this.selectedCohortPatients, + this.studyToMutationMolecularProfile, + this.patientViewPageStore.studyIdToStudy, + this.patientViewPageStore.coverageInformation, + this.filteredSampleKeyToSample, + this.filteredPatientKeyToPatient, + this.customAttributes + ); +} diff --git a/src/shared/components/plots/BoxScatterPlot.tsx b/src/shared/components/plots/BoxScatterPlot.tsx index d94c77f679c..9bfd045b012 100644 --- a/src/shared/components/plots/BoxScatterPlot.tsx +++ b/src/shared/components/plots/BoxScatterPlot.tsx @@ -21,6 +21,7 @@ import { getLegendItemsPerRow, getMaxLegendLabelWidth, LegendDataWithId, + separateScatterData, separateScatterDataByAppearance, } from './PlotUtils'; import { logicalAnd } from '../../lib/LogicUtils'; @@ -107,6 +108,11 @@ export interface IBoxScatterPlotProps { qValue?: number | null; renderLinePlot?: boolean; samplesForPatients?: SampleIdsForPatientIds[] | []; + highlightedSamples?: string[]; + customSamplePointComponent?: ( + sampleId: string, + mouseEvents: any + ) => JSX.Element; } export type BoxModel = { @@ -774,18 +780,38 @@ export default class BoxScatterPlot< let dataAxis: 'x' | 'y' = this.props.horizontal ? 'x' : 'y'; let categoryAxis: 'x' | 'y' = this.props.horizontal ? 'y' : 'x'; const data: (D & { x: number; y: number })[] = []; + const highlightedData: (D & { x: number; y: number })[] = []; for (let i = 0; i < this.props.data.length; i++) { const categoryCoord = this.categoryCoord(i); for (const d of this.props.data[i].data) { - data.push( - Object.assign({}, d, { - [dataAxis]: d.value, - [categoryAxis]: categoryCoord, - } as { x: number; y: number }) - ); + if (this.props.highlightedSamples?.includes(d.sampleId)) { + highlightedData.push( + Object.assign({}, d, { + [dataAxis]: d.value, + [categoryAxis]: categoryCoord, + } as { x: number; y: number }) + ); + } else { + data.push( + Object.assign({}, d, { + [dataAxis]: d.value, + [categoryAxis]: categoryCoord, + } as { x: number; y: number }) + ); + } } } - return separateScatterDataByAppearance( + const highlightedDataBuckets = separateScatterData( + highlightedData, + ifNotDefined(this.props.fill, '0x000000'), + ifNotDefined(this.props.stroke, '0x000000'), + ifNotDefined(this.props.strokeWidth, 0), + ifNotDefined(this.props.strokeOpacity, 1), + ifNotDefined(this.props.fillOpacity, 1), + ifNotDefined(this.props.symbol, 'circle'), + this.props.zIndexSortBy + ); + const unHighlightedDataBuckets = separateScatterDataByAppearance( data, ifNotDefined(this.props.fill, '0x000000'), ifNotDefined(this.props.stroke, '0x000000'), @@ -795,6 +821,8 @@ export default class BoxScatterPlot< ifNotDefined(this.props.symbol, 'circle'), this.props.zIndexSortBy ); + // highlighted data points should appear in front of the other data points + return [...unHighlightedDataBuckets, ...highlightedDataBuckets]; } @computed get leftPadding() { @@ -1105,29 +1133,47 @@ export default class BoxScatterPlot< /> ) )} - {this.scatterPlotData.map(dataWithAppearance => ( - - ))} + {this.scatterPlotData.map(dataWithAppearance => { + const useCustomDataComponent = + this.props.customSamplePointComponent && + this.props.highlightedSamples?.includes( + dataWithAppearance.data[0].sampleId + ); + return ( + + ); + })} diff --git a/src/shared/components/plots/CohortSelector.tsx b/src/shared/components/plots/CohortSelector.tsx new file mode 100644 index 00000000000..0dd5b2dc323 --- /dev/null +++ b/src/shared/components/plots/CohortSelector.tsx @@ -0,0 +1,82 @@ +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { computed } from 'mobx'; +import ReactSelect from 'react-select1'; +import InfoIcon from '../InfoIcon'; + +export interface ICohortSelector { + study: string; + cancerTypes: string[]; + cancerTypesDetailed: string[]; + cohortSelection: CohortOptions; + handleCohortChange: (cohort: { + value: CohortOptions; + label: string; + }) => void; +} + +export enum CohortOptions { + WholeStudy = 'WholeStudy', + CancerType = 'CancerType', + CancerTypeDetailed = 'CancerTypeDetailed', +} + +@observer +export default class CohortSelector extends React.Component< + ICohortSelector, + {} +> { + @computed get cohortOptions(): { + value: CohortOptions; + label: string; + }[] { + return [ + { + value: CohortOptions.WholeStudy, + label: `Study: ${this.props.study}`, + }, + { + value: CohortOptions.CancerType, + label: `Samples with: ${this.props.cancerTypes.join(', ')}`, + }, + { + value: CohortOptions.CancerTypeDetailed, + label: `Samples with: ${this.props.cancerTypesDetailed.join( + ', ' + )}`, + }, + ]; + } + + render() { + return ( +
+
+ +
+ + + Set of patients/samples displayed alongside + the current patient/sample for context + + } + tooltipPlacement="right" + style={{ marginLeft: 7 }} + /> +
+
+
+ ); + } +} diff --git a/src/shared/components/plots/PlotUtils.ts b/src/shared/components/plots/PlotUtils.ts index 870ffa6d946..e0873584703 100644 --- a/src/shared/components/plots/PlotUtils.ts +++ b/src/shared/components/plots/PlotUtils.ts @@ -164,7 +164,7 @@ export function makeScatterPlotSizeFunction( const isLineHighlighted = isHovered || isHighlighted; return size(d, active, isHighlighted, isLineHighlighted); - }; + }; } else { return size; } @@ -279,6 +279,84 @@ export function makeUniqueColorGetter(init_used_colors?: string[]) { }; } +export function separateScatterData( + data: D[], + fill: string | ((d: D) => string), + stroke: string | ((d: D) => string), + strokeWidth: number | ((d: D) => number), + strokeOpacity: number | ((d: D) => number), + fillOpacity: number | ((d: D) => number), + symbol: string | ((d: D) => string), + zIndexSortBy?: ((d: D) => any)[] // second argument to _.sortBy +): { + data: D[]; + fill: string; + stroke: string; + strokeWidth: number; + strokeOpacity: number; + fillOpacity: number; + symbol: string; +}[] { + let buckets: { + data: D[]; + fill: string; + stroke: string; + strokeWidth: number; + strokeOpacity: number; + fillOpacity: number; + symbol: string; + sortBy: any[]; + }[] = []; + + let d_fill: string, + d_stroke: string, + d_strokeWidth: number, + d_strokeOpacity: number, + d_fillOpacity: number, + d_symbol: string, + d_sortBy: any[]; + + for (const datum of data) { + // compute appearance for datum + d_fill = typeof fill === 'function' ? fill(datum) : fill; + d_stroke = typeof stroke === 'function' ? stroke(datum) : stroke; + d_strokeWidth = + typeof strokeWidth === 'function' + ? strokeWidth(datum) + : strokeWidth; + d_strokeOpacity = + typeof strokeOpacity === 'function' + ? strokeOpacity(datum) + : strokeOpacity; + d_fillOpacity = + typeof fillOpacity === 'function' + ? fillOpacity(datum) + : fillOpacity; + d_symbol = typeof symbol === 'function' ? symbol(datum) : symbol; + d_sortBy = zIndexSortBy ? zIndexSortBy.map(f => f(datum)) : [1]; + + buckets.push({ + data: [datum], + fill: d_fill, + stroke: d_stroke, + strokeWidth: d_strokeWidth, + strokeOpacity: d_strokeOpacity, + fillOpacity: d_fillOpacity, + symbol: d_symbol, + sortBy: d_sortBy, + }); + } + + if (zIndexSortBy) { + // sort by sortBy + const sortBy = zIndexSortBy.map( + (f, index) => (bucket: typeof buckets[0]) => bucket.sortBy[index] + ); + buckets = _.sortBy(buckets, sortBy); + } + return buckets; +} + export function separateScatterDataByAppearance( data: D[], fill: string | ((d: D) => string), diff --git a/src/shared/components/plots/PlotsTab.tsx b/src/shared/components/plots/PlotsTab.tsx index 598064a918c..9e7ffe5fb2f 100644 --- a/src/shared/components/plots/PlotsTab.tsx +++ b/src/shared/components/plots/PlotsTab.tsx @@ -12,6 +12,7 @@ import './styles.scss'; import { AlterationTypeConstants, DataTypeConstants } from 'shared/constants'; import { Button, FormControl } from 'react-bootstrap'; import ReactSelect from 'react-select1'; +import 'react-select1/dist/react-select.css'; import AsyncSelect from 'react-select/async'; import Select from 'react-select'; import _ from 'lodash'; @@ -168,6 +169,9 @@ import { FilteredAndAnnotatedMutationsReport } from 'shared/lib/comparison/Analy import { AnnotatedNumericGeneMolecularData } from 'shared/model/AnnotatedNumericGeneMolecularData'; import { ExtendedAlteration } from 'shared/model/ExtendedAlteration'; import CaseFilterWarning from '../banners/CaseFilterWarning'; +import PatientViewUrlWrapper, { + PatientViewUrlQuery, +} from 'pages/patientView/PatientViewUrlWrapper'; enum EventKey { horz_logScale, @@ -344,7 +348,10 @@ export interface IPlotsTabProps { molecularProfileIdToMolecularProfile: MobxPromiseUnionTypeWithDefault<{ [molecularProfileId: string]: MolecularProfile; }>; - urlWrapper: ResultsViewURLWrapper | StudyViewURLWrapper; + urlWrapper: + | ResultsViewURLWrapper + | StudyViewURLWrapper + | PatientViewUrlWrapper; hasNoQueriedGenes?: boolean; genePanelDataForAllProfiles?: GenePanelData[]; queryContainsOql?: boolean; @@ -375,6 +382,12 @@ export interface IPlotsTabProps { patients: MobxPromise; filteredPatients?: MobxPromise; hideUnprofiledSamples?: false | 'any' | 'totally'; + highlightedSamples?: string[]; + additionalControls?: () => JSX.Element; + customSamplePointComponent?: ( + sampleId: string, + mouseEvents: any + ) => JSX.Element; } export type PlotsTabDataSource = { @@ -950,7 +963,10 @@ export default class PlotsTab extends React.Component { } components = [ -
+
{mainMessage}
{components}
} /> @@ -1114,8 +1130,11 @@ export default class PlotsTab extends React.Component { (this._dataType === undefined && dataTypeOptions.length) || selectedDataTypeDoesNotExist ) { - // if no queried genes, default is undefined - if (self.props.hasNoQueriedGenes) { + // if no queried genes and no highlighted samples (study view plots), default is undefined + if ( + self.props.hasNoQueriedGenes && + !self.props.highlightedSamples + ) { return undefined; } // return computed default if _dataType is undefined and if there are options to select a default value from @@ -1393,6 +1412,27 @@ export default class PlotsTab extends React.Component { ? self.props.urlWrapper.query.plots_vert_selection : self.props.urlWrapper.query.plots_horz_selection) || {}; + const localSelection = vertical + ? localStorage.getItem('vertGeneSelection') + : localStorage.getItem('horzGeneSelection'); + // if empty, set gene selection in url to local selection if available + if (!urlSelection.selectedGeneOption && localSelection) { + self.props.urlWrapper.updateURL( + ( + currentParams: + | ResultsViewURLQuery + | StudyViewURLQuery + | PatientViewUrlQuery + ) => { + if (vertical) { + currentParams.plots_vert_selection!.selectedGeneOption = localSelection; + } else { + currentParams.plots_horz_selection!.selectedGeneOption = localSelection; + } + return currentParams; + } + ); + } const param = urlSelection.selectedGeneOption; if (!param) { @@ -1408,9 +1448,17 @@ export default class PlotsTab extends React.Component { } }, set _selectedGeneOption(o: any) { + if (vertical) { + localStorage.setItem('vertGeneSelection', o && o.value); + } else { + localStorage.setItem('horzGeneSelection', o && o.value); + } self.props.urlWrapper.updateURL( ( - currentParams: ResultsViewURLQuery | StudyViewURLQuery + currentParams: + | ResultsViewURLQuery + | StudyViewURLQuery + | PatientViewUrlQuery ) => { if (vertical) { currentParams.plots_vert_selection!.selectedGeneOption = @@ -2122,9 +2170,12 @@ export default class PlotsTab extends React.Component { // listen to updates of `dataTypeOptions` and on the selected data type for the vertical axis if ( this.dataTypeOptions.result && - isGenericAssaySelected(this.vertSelection) && - this.vertSelection.genericAssayDataType === - DataTypeConstants.LIMITVALUE + ((isGenericAssaySelected(this.vertSelection) && + this.vertSelection.genericAssayDataType === + DataTypeConstants.LIMITVALUE) || + (this.vertSelection.dataType && + this.vertSelection.dataType === + AlterationTypeConstants.MRNA_EXPRESSION)) ) { noneDatatypeOption = [ { @@ -2147,9 +2198,12 @@ export default class PlotsTab extends React.Component { // listen to updates of `dataTypeOptions` and on the selected data type for the horzontal axis if ( this.dataTypeOptions.result && - isGenericAssaySelected(this.horzSelection) && - this.horzSelection.genericAssayDataType === - DataTypeConstants.LIMITVALUE + ((isGenericAssaySelected(this.horzSelection) && + this.horzSelection.genericAssayDataType === + DataTypeConstants.LIMITVALUE) || + (this.horzSelection.dataType && + this.horzSelection.dataType === + AlterationTypeConstants.MRNA_EXPRESSION)) ) { noneDatatypeOption = [ { @@ -2223,6 +2277,30 @@ export default class PlotsTab extends React.Component { }, }); + private makeSameGeneOption( + vertical: boolean, + selection: AxisMenuSelection + ) { + if ( + vertical && + selection.dataType && + this.showGeneSelectBox( + selection.dataType, + isGenericAssaySelected(selection) + ) && + selection.selectedGeneOption && + selection.selectedGeneOption.value !== + NONE_SELECTED_OPTION_NUMERICAL_VALUE + ) { + return [ + { + value: SAME_SELECTED_OPTION_NUMERICAL_VALUE, + label: `Same gene (${selection.selectedGeneOption.label})`, + }, + ]; + } + } + readonly clinicalAndCustomAttributes = remoteData< ExtendedClinicalAttribute[] >({ @@ -2597,6 +2675,17 @@ export default class PlotsTab extends React.Component { } @computed get waterfallPlotIsShown(): boolean { + if ( + this.horzAxisDataPromise.isComplete && + this.vertAxisDataPromise.isComplete + ) { + return showWaterfallPlot( + this.horzSelection, + this.vertSelection, + this.horzAxisDataPromise.result, + this.vertAxisDataPromise.result + ); + } return showWaterfallPlot(this.horzSelection, this.vertSelection); } @@ -4225,6 +4314,10 @@ export default class PlotsTab extends React.Component { this.horzGeneOptions .isPending } + defaultOptions={this.makeSameGeneOption( + vertical, + this.horzSelection + )} noOptionsMessage={() => 'Search for gene' } @@ -4820,6 +4913,11 @@ export default class PlotsTab extends React.Component { private controls() { return (
+
+ {this.props.additionalControls && ( + {this.props.additionalControls} + )} +
{this.getHorizontalAxisMenu}
@@ -5761,6 +5859,12 @@ export default class PlotsTab extends React.Component { this.onClickLegendItem )} legendTitle={this.legendTitle} + highlightedSamples={ + this.props.highlightedSamples + } + customSamplePointComponent={ + this.props.customSamplePointComponent + } /> ); break; @@ -5908,6 +6012,12 @@ export default class PlotsTab extends React.Component { samplesForPatients={ this.samplesForEachPatient } + highlightedSamples={ + this.props.highlightedSamples + } + customSamplePointComponent={ + this.props.customSamplePointComponent + } /> ); break; diff --git a/src/shared/components/plots/PlotsTabUtils.tsx b/src/shared/components/plots/PlotsTabUtils.tsx index d22df6f94c3..992cfbced61 100644 --- a/src/shared/components/plots/PlotsTabUtils.tsx +++ b/src/shared/components/plots/PlotsTabUtils.tsx @@ -3299,7 +3299,9 @@ export function getCacheQueries(utilitiesSelection: ColoringMenuSelection) { export function showWaterfallPlot( horzSelection: AxisMenuSelection, - vertSelection: AxisMenuSelection + vertSelection: AxisMenuSelection, + horzAxisData?: IAxisData, + vertAxisData?: IAxisData ): boolean { return ( (vertSelection.dataType !== undefined && @@ -3311,7 +3313,13 @@ export function showWaterfallPlot( isGenericAssaySelected(horzSelection) && horzSelection.genericAssayDataType === DataTypeConstants.LIMITVALUE && - vertSelection.dataType === NONE_SELECTED_OPTION_STRING_VALUE) + vertSelection.dataType === NONE_SELECTED_OPTION_STRING_VALUE) || + (horzAxisData !== undefined && + isNumberData(horzAxisData) && + vertSelection.dataType === NONE_SELECTED_OPTION_STRING_VALUE) || + (vertAxisData !== undefined && + isNumberData(vertAxisData) && + horzSelection.dataType === NONE_SELECTED_OPTION_STRING_VALUE) ); } diff --git a/src/shared/components/plots/ScatterPlot.tsx b/src/shared/components/plots/ScatterPlot.tsx index e86837ece64..480ecc524d0 100644 --- a/src/shared/components/plots/ScatterPlot.tsx +++ b/src/shared/components/plots/ScatterPlot.tsx @@ -21,6 +21,7 @@ import { getBottomLegendHeight, getMaxLegendLabelWidth, getLegendItemsPerRow, + separateScatterData, } from './PlotUtils'; import { toConditionalPrecision } from '../../lib/NumberUtils'; import { @@ -44,6 +45,7 @@ import autobind from 'autobind-decorator'; export interface IBaseScatterPlotData { x: number; y: number; + sampleId: string; } export interface IScatterPlotProps { @@ -79,6 +81,11 @@ export interface IScatterPlotProps { axisLabelY?: string; fontFamily?: string; legendTitle?: string | string[]; + highlightedSamples?: string[]; + customSamplePointComponent?: ( + sampleId: string, + mouseEvents: any + ) => JSX.Element; } // constants related to the gutter const GUTTER_TEXT_STYLE = { @@ -596,8 +603,22 @@ export default class ScatterPlot< } @computed get data() { - return separateScatterDataByAppearance( + const [highlightedData, unHighlightedData] = _.partition( this.props.data, + d => this.props.highlightedSamples?.includes(d.sampleId) + ); + const highlightedDataBuckets = separateScatterData( + highlightedData, + ifNotDefined(this.props.fill, '0x000000'), + ifNotDefined(this.props.stroke, '0x000000'), + ifNotDefined(this.props.strokeWidth, 0), + ifNotDefined(this.props.strokeOpacity, 1), + ifNotDefined(this.props.fillOpacity, 1), + ifNotDefined(this.props.symbol, 'circle'), + this.props.zIndexSortBy + ); + const unHighlightedDataBuckets = separateScatterDataByAppearance( + unHighlightedData, ifNotDefined(this.props.fill, '0x000000'), ifNotDefined(this.props.stroke, '0x000000'), ifNotDefined(this.props.strokeWidth, 0), @@ -606,6 +627,8 @@ export default class ScatterPlot< ifNotDefined(this.props.symbol, 'circle'), this.props.zIndexSortBy ); + // highlighted data points should appear in front of the other data points + return [...unHighlightedDataBuckets, ...highlightedDataBuckets]; } @computed private get regressionLineComputations() { @@ -713,29 +736,47 @@ export default class ScatterPlot< axisLabelComponent={} label={this.axisLabelY} /> - {this.data.map(dataWithAppearance => ( - - ))} + {this.data.map(dataWithAppearance => { + const useCustomDataComponent = + this.props.customSamplePointComponent && + this.props.highlightedSamples?.includes( + dataWithAppearance.data[0].sampleId + ); + return ( + + ); + })} {this.regressionLine} {this.correlationInfo} diff --git a/src/shared/components/plots/styles.scss b/src/shared/components/plots/styles.scss index 1bcccbe739c..d523a4bcf6b 100644 --- a/src/shared/components/plots/styles.scss +++ b/src/shared/components/plots/styles.scss @@ -17,6 +17,10 @@ width: 240px; } + .cohort-select-div .Select { + width: 100%; + } + .Select.is-open.is-searchable { .Select-value-label { color: #aaaaaa !important; diff --git a/src/shared/components/sampleLabel/SampleLabel.tsx b/src/shared/components/sampleLabel/SampleLabel.tsx index eab5dae9557..4d70da5cd77 100644 --- a/src/shared/components/sampleLabel/SampleLabel.tsx +++ b/src/shared/components/sampleLabel/SampleLabel.tsx @@ -84,3 +84,42 @@ interface ISampleLabelHTMLProps { color: string; fillOpacity: number; } + +export class SamplePointLabel extends React.Component< + ISamplePointLabelProps, + {} +> { + public render() { + const { x = 6, y = 6, events = {}, label, ...restProps } = this.props; + const { onMouseOver, onMouseOut } = events; + return ( + <> + + + + + + {label} + + + + ); + } +} + +interface ISamplePointLabelProps { + label: string; + events: any; + x?: number; + y?: number; +}