diff --git a/src/pages/resultsView/ResultsViewPage.tsx b/src/pages/resultsView/ResultsViewPage.tsx index 278baf31af3..cdb95cded90 100644 --- a/src/pages/resultsView/ResultsViewPage.tsx +++ b/src/pages/resultsView/ResultsViewPage.tsx @@ -260,13 +260,14 @@ export default class ResultsViewPage extends React.Componentstore.expressionTabSeqVersion=version} diff --git a/src/pages/resultsView/ResultsViewPageStore.ts b/src/pages/resultsView/ResultsViewPageStore.ts index 9a2dd5f74b8..d7dff46e27e 100644 --- a/src/pages/resultsView/ResultsViewPageStore.ts +++ b/src/pages/resultsView/ResultsViewPageStore.ts @@ -1768,6 +1768,39 @@ export class ResultsViewPageStore { } }); + public annotatedCnaCache = + new MobxPromiseCache<{entrezGeneId:number}, AnnotatedNumericGeneMolecularData[]>( + q=>({ + await: ()=>this.numericGeneMolecularDataCache.await( + [this.studyToMolecularProfileDiscrete, this.entrezGeneIdToGene, this.getOncoKbCnaAnnotationForOncoprint, this.molecularProfileIdToMolecularProfile], + (studyToMolecularProfileDiscrete)=>{ + return _.values(studyToMolecularProfileDiscrete).map(p=>({entrezGeneId: q.entrezGeneId, molecularProfileId: p.molecularProfileId})); + }), + invoke:()=>{ + const results = _.flatten(this.numericGeneMolecularDataCache.getAll( + _.values(this.studyToMolecularProfileDiscrete.result!).map(p=>({entrezGeneId: q.entrezGeneId, molecularProfileId: p.molecularProfileId})) + ).map(p=>p.result!)); + const entrezGeneIdToGene = this.entrezGeneIdToGene.result!; + let getOncoKbAnnotation:(datum:NumericGeneMolecularData)=>IndicatorQueryResp|undefined; + if (this.getOncoKbCnaAnnotationForOncoprint.result! instanceof Error) { + getOncoKbAnnotation = ()=>undefined; + } else { + getOncoKbAnnotation = this.getOncoKbCnaAnnotationForOncoprint.result! as typeof getOncoKbAnnotation; + } + const profileIdToProfile = this.molecularProfileIdToMolecularProfile.result!; + return Promise.resolve(results.map(d=>{ + return annotateMolecularDatum( + d, + getOncoKbAnnotation, + profileIdToProfile, + entrezGeneIdToGene + ); + }) + ); + } + }) + ); + readonly getPutativeDriverInfo = remoteData({ await:()=>{ const toAwait = []; diff --git a/src/pages/resultsView/expression/ExpressionWrapper.spec.tsx b/src/pages/resultsView/expression/ExpressionWrapper.spec.tsx index b03561574d4..c905377cfc9 100644 --- a/src/pages/resultsView/expression/ExpressionWrapper.spec.tsx +++ b/src/pages/resultsView/expression/ExpressionWrapper.spec.tsx @@ -18,30 +18,8 @@ describe('Expression Wrapper',()=>{ instance = wrapper.instance() as ExpressionWrapper; }); - - it('data transformer returns correct value based on logScale state',()=>{ - - instance.logScale = true; - assert.equal(instance.dataTransformer({value: 100} as NumericGeneMolecularData), 6.643856189774724); - - instance.logScale = false; - assert.equal(instance.dataTransformer({value: 100} as NumericGeneMolecularData), 100); - - }); - - it('data transformer caps expression value >= constant',()=>{ - - instance.logScale = true; - assert.equal(instance.dataTransformer({value: 0.0001} as NumericGeneMolecularData), -6.643856189774724, 'value less than cap'); - assert.equal(instance.dataTransformer({value: 0} as NumericGeneMolecularData), -6.643856189774724, 'zero value'); - - instance.logScale = false; - assert.equal(instance.dataTransformer({value: 0.0001} as NumericGeneMolecularData), .0001, 'non log'); - assert.equal(instance.dataTransformer({value: 0} as NumericGeneMolecularData), 0, 'non log zero'); - }); - // this is failing. we need new data to debug - it.skip('studies are sorted properly depending on sortBy setting',()=>{ + /*it.skip('studies are sorted properly depending on sortBy setting',()=>{ instance.sortBy = "alphabetic"; assert.equal(instance.sortedData[2][0].studyId, 'chol_tcga', 'sorting is alphabetical'); @@ -49,6 +27,6 @@ describe('Expression Wrapper',()=>{ instance.sortBy = "median"; assert.equal(instance.sortedData[2][0].studyId, 'laml_tcga', 'sort according to median values'); - }); + });*/ }); \ No newline at end of file diff --git a/src/pages/resultsView/expression/ExpressionWrapper.tsx b/src/pages/resultsView/expression/ExpressionWrapper.tsx index c26f7c52625..e51b0ba3c89 100644 --- a/src/pages/resultsView/expression/ExpressionWrapper.tsx +++ b/src/pages/resultsView/expression/ExpressionWrapper.tsx @@ -1,14 +1,14 @@ import * as React from 'react'; import * as _ from 'lodash'; -import {observer} from "mobx-react"; +import {observer, Observer} from "mobx-react"; import './styles.scss'; import { - CancerStudy, Gene, NumericGeneMolecularData, Mutation + CancerStudy, Gene, NumericGeneMolecularData, Mutation, MolecularProfile } from "../../../shared/api/generated/CBioPortalAPI"; import {action, computed, observable} from "mobx"; import getCanonicalMutationType from "../../../shared/lib/getCanonicalMutationType"; import { - calculateJitter, ExpressionStyle, ExpressionStyleSheet, getExpressionStyle, + calculateJitter, ExpressionStyle, ExpressionStyleSheet, expressionTooltip, getExpressionStyle, getMolecularDataBuckets, prioritizeMutations } from "./expressionHelpers"; import {Modal} from "react-bootstrap"; @@ -28,29 +28,48 @@ import classNames from 'classnames'; import {MSKTab, MSKTabs} from "../../../shared/components/MSKTabs/MSKTabs"; import {CoverageInformation, isPanCanStudy, isTCGAProvStudy} from "../ResultsViewPageStoreUtils"; import {sleep} from "../../../shared/lib/TimeUtils"; -import {mutationRenderPriority} from "../plots/PlotsTabUtils"; +import { + CNA_STROKE_WIDTH, + getCnaQueries, IBoxScatterPlotPoint, INumberAxisData, IScatterPlotData, IScatterPlotSampleData, IStringAxisData, + makeBoxScatterPlotData, makeScatterPlotPointAppearance, + mutationRenderPriority, MutationSummary, mutationSummaryToAppearance, scatterPlotLegendData, scatterPlotSize, + scatterPlotTooltip, boxPlotTooltip, scatterPlotZIndexSortBy +} from "../plots/PlotsTabUtils"; import {getOncoprintMutationType} from "../../../shared/components/oncoprint/DataUtils"; import {getSampleViewUrl} from "../../../shared/api/urls"; -import {ResultsViewPageStore} from "../ResultsViewPageStore"; +import {AnnotatedMutation, ResultsViewPageStore} from "../ResultsViewPageStore"; import OqlStatusBanner from "../../../shared/components/oqlStatusBanner/OqlStatusBanner"; +import {remoteData} from "../../../shared/api/remoteData"; +import MobxPromiseCache from "../../../shared/lib/MobxPromiseCache"; +import {MobxPromise} from "mobxpromise"; +import {stringListToSet} from "../../../shared/lib/StringUtils"; +import LoadingIndicator from "shared/components/loadingIndicator/LoadingIndicator"; +import BoxScatterPlot from "../../../shared/components/plots/BoxScatterPlot"; +import {ViewType} from "../plots/PlotsTab"; +import DownloadControls from "../../../shared/components/downloadControls/DownloadControls"; export interface ExpressionWrapperProps { store:ResultsViewPageStore; + expressionProfiles:MobxPromise; + numericGeneMolecularDataCache:MobxPromiseCache<{entrezGeneId:number, molecularProfileId:string}, NumericGeneMolecularData[]>; studyMap: { [studyId: string]: CancerStudy }; genes: Gene[]; - data: { [hugeGeneSymbol: string]: NumericGeneMolecularData[][] }; - mutations: Mutation[]; + mutations: AnnotatedMutation[]; onRNASeqVersionChange: (version: number) => void; RNASeqVersion: number; coverageInformation: CoverageInformation } +class ExpressionTabBoxPlot extends BoxScatterPlot {} + const SYMBOL_SIZE = 3; const EXPRESSION_CAP = .01; const LOGGING_FUNCTION = Math.log2; +const SVG_ID = "expression-tab-plot-svg"; + export type ScatterPoint = { x: number, y: NumericGeneMolecularData }; type SortOptions = "alphabetic" | "median"; @@ -70,26 +89,28 @@ export default class ExpressionWrapper extends React.Componenttrue); + this.selectedStudyIds = _.mapValues(this.props.studyMap,(study)=>true); } svgContainer: SVGElement; @observable.ref tooltipModel: ITooltipModel | null = null; - @observable selectedGene: string; + @observable.ref selectedGene: Gene; @observable studySelectorModalVisible = false; @observable showMutations: boolean = true; - @observable _selectedStudies: { [studyId: string]: boolean } = {}; + @observable showCna: boolean = true; + + @observable selectedStudyIds: { [studyId: string]: boolean } = {}; @observable height = 700; @@ -104,26 +125,8 @@ export default class ExpressionWrapper extends React.Component this._selectedStudies[data[0].studyId] === true); - } - - @computed get dataByStudyId(): { [studyId:string] : NumericGeneMolecularData[] } { - return _.keyBy(this.props.data[this.selectedGene], (data: NumericGeneMolecularData[]) => data[0].studyId); - } - @computed get selectedStudies() { - return _.filter(this.props.studyMap,(study)=>this._selectedStudies[study.studyId] === true); - } - - @computed get containerWidth() { - const legendBased = (this.legendData.length * 80) + 200; - return (legendBased > this.chartWidth) ? legendBased : this.chartWidth; - } - - @computed get chartWidth() { - return this.sortedLabels.length * (this.widthThreshold ? 30 : 110) + 200; + return _.filter(this.props.studyMap,(study)=>this.selectedStudyIds[study.studyId] === true); } @computed get widthThreshold(){ @@ -131,173 +134,162 @@ export default class ExpressionWrapper extends React.Component { - return this.props.studyMap[data[0].studyId].shortName - }] + // linearly decrease 40 with 1 box->minimum 18 with 33 boxes + // calbirated to fit all 33 tcga pan-can atlas studies + const m = -22/32; + const b = 40 - m; + return Math.max(m * this.selectedStudies.length + b, 18); + } + + readonly sampleStudyData = remoteData({ + await:()=>[this.props.store.samples], + invoke: ()=>{ + // data representing the horizontal axis - which sample is in which study + return Promise.resolve( + { + data: this.props.store.samples.result!.reduce((_data, sample)=>{ + // filter out data for unselected studies + if (this.selectedStudyIds[sample.studyId]) { + _data.push({ + uniqueSampleKey: sample.uniqueSampleKey, + value: this.props.studyMap[sample.studyId].shortName + }); + } + return _data; + }, [] as IStringAxisData["data"]), + datatype: "string" + } ); - } else { - return _.sortBy(this.filteredData, [(data: NumericGeneMolecularData[]) => { - //Note: we have to use slice to convert Seamless immutable array to real array, otherwise jStat chokes - return jStat.median(Array.prototype.slice((data.map((molecularData: NumericGeneMolecularData) => molecularData.value) as any))) as number; - }]); } - } - - @computed get sortedStudies() { - return _.map(this.sortedData, (data: NumericGeneMolecularData[]) => { - return this.props.studyMap[data[0].studyId]; - }); - } - - @computed get studyTypeCounts(){ - const allStudies = _.values(this.props.studyMap); - return { - provisional:allStudies.filter((study)=>isTCGAProvStudy(study.studyId)), - panCancer:allStudies.filter((study)=>isPanCanStudy(study.studyId)) + }); + + readonly expressionData = remoteData({ + await:()=>this.props.numericGeneMolecularDataCache.await([this.props.expressionProfiles], + profiles=>profiles.map((p:MolecularProfile)=>({ entrezGeneId: this.selectedGene.entrezGeneId, molecularProfileId: p.molecularProfileId })) + ), + invoke:()=>{ + // TODO: this cache is leading to some ugly code + return Promise.resolve(_.flatten(this.props.numericGeneMolecularDataCache.getAll( + this.props.expressionProfiles.result!.map(p=>({ entrezGeneId: this.selectedGene.entrezGeneId, molecularProfileId: p.molecularProfileId })) + ).map(promise=>promise.result!)) as NumericGeneMolecularData[]); } - } - - @computed get sortedLabels(){ - return this.sortedStudies.map((study:CancerStudy)=>study.shortName); - } - - @computed get mutationsByGene(){ - return _.groupBy(this.props.mutations,(mutation:Mutation)=>mutation.gene.hugoGeneSymbol); - } + }); + + readonly studiesWithExpressionData = remoteData<{[studyId:string]:boolean}>({ + await:()=>[this.expressionData], + invoke:()=>{ + const studyIds = _.chain(this.expressionData.result!) + .map(d=>d.studyId) + .uniq() + .value(); + return Promise.resolve(stringListToSet(studyIds)); + } + }); - @computed get mutationsKeyedBySampleId() { - const mutations = this.mutationsByGene[this.selectedGene] || []; + readonly mutationDataExists = remoteData({ + await: ()=>[this.props.store.studyToMutationMolecularProfile], + invoke: ()=>{ + return Promise.resolve(!!_.values(this.props.store.studyToMutationMolecularProfile).length); + } + }); - // find if there are any mutations which apply to sample sample - const groups = _.groupBy(mutations,(mutation: Mutation) => mutation.uniqueSampleKey); + readonly cnaDataExists = remoteData({ + await: ()=>[this.props.store.studyToMolecularProfileDiscrete], + invoke: ()=>{ + return Promise.resolve(!!_.values(this.props.store.studyToMolecularProfileDiscrete).length); + } + }); - const sampleIdToMutationMap = _.mapValues(groups,(mutations:Mutation[])=>{ - if (mutations.length > 1) { - return prioritizeMutations(mutations)[0]; + readonly cnaData = remoteData({ + await:()=>{ + if (this.cnaDataShown && this.selectedGene !== undefined) { + return [this.props.store.annotatedCnaCache.get({ entrezGeneId: this.selectedGene.entrezGeneId })]; } else { - return mutations[0]; + return []; + } + }, + invoke:()=>{ + if (this.cnaDataShown && this.selectedGene !== undefined) { + return Promise.resolve(this.props.store.annotatedCnaCache.get({ entrezGeneId: this.selectedGene.entrezGeneId }).result!); + } else { + return Promise.resolve([]); } - }); - - return sampleIdToMutationMap; - } - - @computed - get dataTransformer() { - function logger(expressionValue: number) { - return (expressionValue < EXPRESSION_CAP) ? LOGGING_FUNCTION(EXPRESSION_CAP) : LOGGING_FUNCTION(expressionValue); - } - - return (molecularData: NumericGeneMolecularData) => - (this.logScale ? logger(molecularData.value) : molecularData.value); - } - - // we need to refactor victoryTraces to make it more easy to test - - @computed get boxTraces(){ - const boxTraces: Partial[] = []; - const sortedData = this.sortedData; - - for (let i = 0; i < sortedData.length; i++) { - const studyData = sortedData[i]; - - let transformedData = studyData; - - transformedData = transformedData.filter((molecularData: NumericGeneMolecularData) => molecularData.value > 0); - - const boxData = calculateBoxPlotModel(transformedData.map(this.dataTransformer)); - - // *IMPORTANT* because Victory does not handle outliers, - // we are overriding meaning of min and max in order to show whiskers - // at quartile +/-IQL * 1.5 instead of true max/min - boxTraces.push({ - realMin: boxData.min, - realMax: boxData.max, - min: boxData.whiskerLower, // see above - median: boxData.median, // see above - max: boxData.whiskerUpper, - q1: boxData.q1, - q3: boxData.q3, - x: i, - }); - } - - return boxTraces; - - } - - @computed get mutationTraces(){ - const mutationScatterTraces: { [key: string]: ScatterPoint[] }[] = []; - const sortedData = this.sortedData; - for (let i = 0; i < sortedData.length; i++) { - const studyData = sortedData[i]; - const buckets = getMolecularDataBuckets(studyData, this.showMutations, this.mutationsKeyedBySampleId, this.props.coverageInformation, this.selectedGene); - const mutationTraces = _.mapValues(buckets.mutationBuckets, (molecularData: NumericGeneMolecularData[], canonicalMutationType: string) => { - return molecularData.map((datum) => { - return {y: datum, x: i + calculateJitter(datum.uniqueSampleKey)} - }); - }); - mutationScatterTraces.push(mutationTraces); + }); + + @computed get mutationDataShown() { + return this.mutationDataExists.result && this.showMutations; + } + + @computed get cnaDataShown() { + return this.cnaDataExists.result && this.showCna; + } + + readonly _unsortedBoxPlotData = remoteData({ + await:()=>[ + this.sampleStudyData, + this.expressionData, + this.props.store.sampleKeyToSample, + this.props.store.studyToMutationMolecularProfile, + this.props.store.studyToMolecularProfileDiscrete, + this.cnaData + ], + invoke:()=>{ + return Promise.resolve(makeBoxScatterPlotData( + this.sampleStudyData.result!, + { + data: this.expressionData.result!, + datatype: "number", + hugoGeneSymbol: this.selectedGene.hugoGeneSymbol + } as INumberAxisData, + this.props.store.sampleKeyToSample.result!, + this.props.coverageInformation.samples, + this.mutationDataExists.result ? { + molecularProfileIds: _.values(this.props.store.studyToMutationMolecularProfile.result!).map(p=>p.molecularProfileId), + data: this.props.mutations + } : undefined, + this.cnaDataShown ? { + molecularProfileIds: _.values(this.props.store.studyToMolecularProfileDiscrete.result!).map(p=>p.molecularProfileId), + data: this.cnaData.result! + }: undefined + )); } - return mutationScatterTraces; - } - - @computed get molecularDataByMutationType(){ - return this.sortedData.map((studyData)=>{ - return getMolecularDataBuckets(studyData, this.showMutations, this.mutationsKeyedBySampleId, this.props.coverageInformation, this.selectedGene); - }); - } - - @computed get unmutatedTraces(){ - const unMutatedTraces: ScatterPoint[][] = []; - const sortedData = this.sortedData; - for (let i = 0; i < sortedData.length; i++) { - const studyData = sortedData[i]; - const buckets = this.molecularDataByMutationType[i]; - const unmutatedTrace = buckets.unmutatedBucket.map((datum: NumericGeneMolecularData) => { - return {y: datum, x: i + calculateJitter(datum.uniqueSampleKey)} - }); - unMutatedTraces.push(unmutatedTrace); + }); + + readonly boxPlotData = remoteData({ + await:()=>[this._unsortedBoxPlotData], + invoke:()=>{ + /*// sort data order within boxes, for rendering order (z-index) + const sortedData = this._unsortedBoxPlotData.result!.map(labelAndData=>({ + label: labelAndData.label, + data: sortScatterPlotDataForZIndex(labelAndData.data, this.viewType) + }));*/ + const sortedData = this._unsortedBoxPlotData.result!; + + // sort box order + if (this.sortBy === "alphabetic") { + return Promise.resolve(_.sortBy(sortedData, d=>d.label)); + } else { + return Promise.resolve(_.sortBy(sortedData, d=>{ + //Note: we have to use slice to convert Seamless immutable array to real array, otherwise jStat chokes + return jStat.median(Array.prototype.slice((d.data.map((v:any)=>(v.value as number))))); + })); + } } - return unMutatedTraces; - } + }); - @computed get unsequencedTraces(){ - const unsequencedTraces: ScatterPoint[][] = []; - const sortedData = this.sortedData; - for (let i = 0; i < sortedData.length; i++) { - // get buckets for this study - const buckets = this.molecularDataByMutationType[i]; - const unsequencedTrace = buckets.unsequencedBucket.map((datum: NumericGeneMolecularData) => { - return {y: datum, x: i + calculateJitter(datum.uniqueSampleKey)} - }); - unsequencedTraces.push(unsequencedTrace); + @computed get studyTypeCounts(){ + const allStudies = _.values(this.props.studyMap); + return { + provisional:allStudies.filter((study)=>isTCGAProvStudy(study.studyId)), + panCancer:allStudies.filter((study)=>isPanCanStudy(study.studyId)) } - return unsequencedTraces; - } - - @computed get victoryTraces() { - return {boxTraces:this.boxTraces, mutationScatterTraces: this.mutationTraces, unSequencedTraces:this.unsequencedTraces, unMutatedTraces: this.unmutatedTraces}; - } - - @computed - get domain() { - const min = _.min(this.victoryTraces.boxTraces.map(trace => trace.realMin)); - const max = _.max(this.victoryTraces.boxTraces.map(trace => trace.realMax)); - return {min, max}; } @autobind handleStudySelection(event: React.SyntheticEvent) { // toggle state of it - this._selectedStudies[event.currentTarget.value] = !this._selectedStudies[event.currentTarget.value]; + this.selectedStudyIds[event.currentTarget.value] = !this.selectedStudyIds[event.currentTarget.value]; } @autobind @@ -312,9 +304,11 @@ export default class ExpressionWrapper extends React.Component{ - return study.studyId in this.dataByStudyId; - }); + if (this.studiesWithExpressionData.isComplete) { + this.applyStudyFilter((study)=>{ + return study.studyId in this.studiesWithExpressionData.result!; + }); + } } @autobind @@ -325,11 +319,15 @@ export default class ExpressionWrapper extends React.Component{ - const hasData = study.studyId in this.dataByStudyId; - const isSelected = this.selectedStudies.includes(study); - return hasData && !isSelected; - }); + if (this.studiesWithExpressionData.isComplete) { + return undefined !== _.find(this.props.studyMap,(study)=>{ + const hasData = study.studyId in this.studiesWithExpressionData.result!; + const isSelected = this.selectedStudies.includes(study); + return hasData && !isSelected; + }); + } else { + return false; + } } @computed @@ -357,12 +355,12 @@ export default class ExpressionWrapper extends React.Component { _.map(this.alphabetizedStudies, (study: CancerStudy) => { - const hasData = study.studyId in this.dataByStudyId; + const hasData = (study.studyId in (this.studiesWithExpressionData.result || {})); return (
-
-
Sort By:
+ {(this.boxPlotData.isComplete && this.boxPlotData.result.length > 1) && ( +
+
Sort By:
- -
+ +
+ )}
- } + { this.cnaDataExists.result && }
-
+ { this.studiesWithExpressionData.isComplete &&
  0}> -
+
} + { this.studiesWithExpressionData.isPending && }
- 0}> + { this.boxPlotData.isComplete && 0}> - 0}> + 0}>
- - this.svgContainer} - exportFileName="Expression" - > - {(this.tooltipModel) && (this.toolTip)} - {this.chart} - - - + + {this.getChart} + + {this.mutationDataExists.result && ( +
* Driver annotation settings are located in the Mutation Color menu of the Oncoprint.
+ )}
@@ -790,7 +633,8 @@ export default class ExpressionWrapper extends React.Component -
+
} + { this.boxPlotData.isPending && } ); } diff --git a/src/pages/resultsView/expression/expressionHelpers.ts b/src/pages/resultsView/expression/expressionHelpers.tsx similarity index 80% rename from src/pages/resultsView/expression/expressionHelpers.ts rename to src/pages/resultsView/expression/expressionHelpers.tsx index 71d530c02f6..887fd97d66b 100644 --- a/src/pages/resultsView/expression/expressionHelpers.ts +++ b/src/pages/resultsView/expression/expressionHelpers.tsx @@ -1,15 +1,23 @@ import * as _ from 'lodash'; -import {GenePanelData, Mutation, NumericGeneMolecularData} from "../../../shared/api/generated/CBioPortalAPI"; +import { + CancerStudy, GenePanelData, Mutation, + NumericGeneMolecularData +} from "../../../shared/api/generated/CBioPortalAPI"; import getCanonicalMutationType, {getProteinImpactType} from "../../../shared/lib/getCanonicalMutationType"; import {CoverageInformation} from "../ResultsViewPageStoreUtils"; import {isSampleProfiled} from "../../../shared/lib/isSampleProfiled"; import {getOncoprintMutationType} from "../../../shared/components/oncoprint/DataUtils"; -import {mutationRenderPriority} from "../plots/PlotsTabUtils"; +import { + IBoxScatterPlotPoint, mutationRenderPriority, tooltipCnaSection, + tooltipMutationsSection +} from "../plots/PlotsTabUtils"; import { MUT_COLOR_FUSION, MUT_COLOR_INFRAME, MUT_COLOR_MISSENSE, MUT_COLOR_PROMOTER, MUT_COLOR_TRUNC } from "../../../shared/components/oncoprint/geneticrules"; import {getJitterForCase} from "../../../shared/components/plots/PlotUtils"; +import * as React from "react"; +import {getSampleViewUrl, getStudySummaryUrl} from "../../../shared/api/urls"; export type ExpressionStyle = { typeName: string; @@ -169,3 +177,30 @@ export function prioritizeMutations(mutations:Mutation[]){ }); } +export function expressionTooltip(d:IBoxScatterPlotPoint, studyIdToStudy:{[studyId:string]:CancerStudy}) { + let mutations = null; + let cna = null; + + if (d.mutations.length > 0) { + mutations = tooltipMutationsSection(d.mutations); + } + + if (d.copyNumberAlterations.length > 0) { + cna = tooltipCnaSection(d.copyNumberAlterations); + } + + return ( +
+ Study: {studyIdToStudy[d.studyId].name}
+ Sample ID: {d.sampleId}
+ Expression: {d.value}
+ { !!mutations && ( + Mutations: {mutations} + )} + { !!mutations &&
} + { !!cna && ( + CNA: {cna} + )} +
+ ); +} \ No newline at end of file diff --git a/src/pages/resultsView/plots/PlotsTab.tsx b/src/pages/resultsView/plots/PlotsTab.tsx index 5fb28ddfbf7..f65dd3db78b 100644 --- a/src/pages/resultsView/plots/PlotsTab.tsx +++ b/src/pages/resultsView/plots/PlotsTab.tsx @@ -16,7 +16,7 @@ import { getCnaQueries, getMutationQueries, getScatterPlotDownloadData, getBoxPlotDownloadData, getTablePlotDownloadData, mutationRenderPriority, mutationSummaryRenderPriority, MutationSummary, mutationSummaryToAppearance, CNA_STROKE_WIDTH, scatterPlotSize, PLOT_SIDELENGTH, CLIN_ATTR_DATA_TYPE, - sortScatterPlotDataForZIndex, sortMolecularProfilesForDisplay + sortMolecularProfilesForDisplay, scatterPlotZIndexSortBy } from "./PlotsTabUtils"; import { ClinicalAttribute, MolecularProfile, Mutation, @@ -678,33 +678,26 @@ export default class PlotsTab extends React.Component { } @computed get cnaDataCanBeShown() { - return this.cnaDataExists && this.potentialViewType === PotentialViewType.MutationTypeAndCopyNumber; + return !!(this.cnaDataExists.result && this.potentialViewType === PotentialViewType.MutationTypeAndCopyNumber); } @computed get cnaDataShown() { - return this.cnaDataExists && (this.viewType === ViewType.CopyNumber || this.viewType === ViewType.MutationTypeAndCopyNumber); + return !!(this.cnaDataExists.result && (this.viewType === ViewType.CopyNumber || this.viewType === ViewType.MutationTypeAndCopyNumber)); } readonly cnaPromise = remoteData({ - await:()=>this.props.store.numericGeneMolecularDataCache.await( - [this.props.store.studyToMolecularProfileDiscrete], - map=>{ - if (this.cnaDataShown && this.horzSelection.entrezGeneId !== undefined) { - return getCnaQueries(this.horzSelection.entrezGeneId, map, this.cnaDataShown); - } else { - return []; - } + await:()=>{ + const queries = getCnaQueries(this.horzSelection, this.vertSelection, this.cnaDataShown); + if (queries.length > 0) { + return this.props.store.annotatedCnaCache.getAll(queries); + } else { + return []; } - ), + }, invoke:()=>{ - if (this.cnaDataShown && this.horzSelection.entrezGeneId !== undefined) { - const queries = getCnaQueries( - this.horzSelection.entrezGeneId, - this.props.store.studyToMolecularProfileDiscrete.result!, - this.cnaDataShown - ); - const promises = this.props.store.numericGeneMolecularDataCache.getAll(queries); - return Promise.resolve(_.flatten(promises.map(p=>p.result!)).filter(x=>!!x)); + const queries = getCnaQueries(this.horzSelection, this.vertSelection, this.cnaDataShown); + if (queries.length > 0) { + return Promise.resolve(_.flatten(this.props.store.annotatedCnaCache.getAll(queries).map(p=>p.result!))); } else { return Promise.resolve([]); } @@ -712,13 +705,13 @@ export default class PlotsTab extends React.Component { }); @computed get mutationDataCanBeShown() { - return this.mutationDataExists.result && this.potentialViewType !== PotentialViewType.None; + return !!(this.mutationDataExists.result && this.potentialViewType !== PotentialViewType.None); } @computed get mutationDataShown() { - return this.mutationDataExists && + return !!(this.mutationDataExists.result && (this.viewType === ViewType.MutationType || this.viewType === ViewType.MutationSummary || - this.viewType === ViewType.MutationTypeAndCopyNumber); + this.viewType === ViewType.MutationTypeAndCopyNumber)); } readonly mutationPromise = remoteData({ @@ -861,16 +854,18 @@ export default class PlotsTab extends React.Component { @autobind private scatterPlotTooltip(d:IScatterPlotData) { - return scatterPlotTooltip(d, this.props.store.entrezGeneIdToGene); + return scatterPlotTooltip(d); } @computed get boxPlotTooltip() { return (d:IBoxScatterPlotPoint)=>{ + let content; if (this.boxPlotData.isComplete) { - return boxPlotTooltip(d, this.props.store.entrezGeneIdToGene, this.boxPlotData.result.horizontal); + content = boxPlotTooltip(d, this.boxPlotData.result.horizontal); } else { - return Loading... (this shouldnt appear because the box plot shouldnt be visible); + content = Loading... (this shouldnt appear because the box plot shouldnt be visible); } + return content; } } @@ -1094,7 +1089,7 @@ export default class PlotsTab extends React.Component { } }); - readonly _unsortedScatterPlotData = remoteData({ + readonly scatterPlotData = remoteData({ await: ()=>[ this.horzAxisDataPromise, this.vertAxisDataPromise, @@ -1117,7 +1112,7 @@ export default class PlotsTab extends React.Component { vertAxisData, this.props.store.sampleKeyToSample.result!, this.props.store.coverageInformation.result!.samples, - this.mutationDataExists ? { + this.mutationDataExists.result ? { molecularProfileIds: _.values(this.props.store.studyToMutationMolecularProfile.result!).map(p=>p.molecularProfileId), data: this.mutationPromise.result! } : undefined, @@ -1133,19 +1128,7 @@ export default class PlotsTab extends React.Component { } }); - readonly scatterPlotData = remoteData({ - await:()=>[this._unsortedScatterPlotData], - invoke:()=>{ - // Sort data to put some data on top (z-index order) - return Promise.resolve(sortScatterPlotDataForZIndex( - this._unsortedScatterPlotData.result!, - this.viewType, - this.scatterPlotHighlight - )); - } - }); - - readonly _unsortedBoxPlotData = remoteData<{horizontal:boolean, data:IBoxScatterPlotData[]}>({ + readonly boxPlotData = remoteData<{horizontal:boolean, data:IBoxScatterPlotData[]}>({ await: ()=>[ this.horzAxisDataPromise, this.vertAxisDataPromise, @@ -1182,7 +1165,7 @@ export default class PlotsTab extends React.Component { categoryData, numberData, this.props.store.sampleKeyToSample.result!, this.props.store.coverageInformation.result!.samples, - this.mutationDataExists ? { + this.mutationDataExists.result ? { molecularProfileIds: _.values(this.props.store.studyToMutationMolecularProfile.result!).map(p=>p.molecularProfileId), data: this.mutationPromise.result! } : undefined, @@ -1196,19 +1179,12 @@ export default class PlotsTab extends React.Component { }, }); - readonly boxPlotData = remoteData<{horizontal:boolean, data:IBoxScatterPlotData[]}>({ - await: ()=>[this._unsortedBoxPlotData], - invoke:()=>{ - // Sort data to put some data on top (z-index order) - const horizontal = this._unsortedBoxPlotData.result!.horizontal; - let boxPlotData = this._unsortedBoxPlotData.result!.data; - boxPlotData = boxPlotData.map(labelAndData=>({ - label: labelAndData.label, - data: sortScatterPlotDataForZIndex(labelAndData.data, this.viewType, this.scatterPlotHighlight) - })); - return Promise.resolve({ horizontal, data: boxPlotData }); - } - }); + @computed get zIndexSortBy() { + return scatterPlotZIndexSortBy( + this.viewType, + this.scatterPlotHighlight + ); + } @computed get boxPlotBoxWidth() { const SMALL_BOX_WIDTH = 30; @@ -1268,6 +1244,7 @@ export default class PlotsTab extends React.Component { fill={this.scatterPlotFill} stroke={this.scatterPlotStroke} strokeOpacity={this.scatterPlotStrokeOpacity} + zIndexSortBy={this.zIndexSortBy} symbol="circle" fillOpacity={this.scatterPlotFillOpacity} strokeWidth={this.scatterPlotStrokeWidth} @@ -1289,6 +1266,7 @@ export default class PlotsTab extends React.Component { plotElt = ( { fill={this.scatterPlotFill} stroke={this.scatterPlotStroke} strokeOpacity={this.scatterPlotStrokeOpacity} + zIndexSortBy={this.zIndexSortBy} symbol="circle" fillOpacity={this.scatterPlotFillOpacity} strokeWidth={this.scatterPlotStrokeWidth} diff --git a/src/pages/resultsView/plots/PlotsTabUtils.tsx b/src/pages/resultsView/plots/PlotsTabUtils.tsx index 3dea73dce3a..096113fb76a 100644 --- a/src/pages/resultsView/plots/PlotsTabUtils.tsx +++ b/src/pages/resultsView/plots/PlotsTabUtils.tsx @@ -27,7 +27,7 @@ import { } from "../../../shared/components/oncoprint/geneticrules"; import {CoverageInformation} from "../ResultsViewPageStoreUtils"; import {IBoxScatterPlotData} from "../../../shared/components/plots/BoxScatterPlot"; -import {AlterationTypeConstants, AnnotatedMutation} from "../ResultsViewPageStore"; +import {AlterationTypeConstants, AnnotatedMutation, AnnotatedNumericGeneMolecularData} from "../ResultsViewPageStore"; import numeral from "numeral"; import {getUniqueSampleKeyToCategories} from "../../../shared/components/plots/TablePlotUtils"; import client from "../../../shared/api/cbioportalClientInstance"; @@ -100,21 +100,23 @@ export enum MutationSummary { } const NOT_PROFILED_MUTATION_LEGEND_LABEL = ["Not profiled","for mutations"]; -const NOT_PROFILED_CNA_LEGEND_LABEL = ["Not profiled", "for copy number", "alterations"]; +const NOT_PROFILED_CNA_LEGEND_LABEL = ["Not profiled", "for CNA"]; const MUTATION_TYPE_NOT_PROFILED = "not_profiled_mutation"; const MUTATION_TYPE_NOT_MUTATED = "not_mutated"; +const CNA_TYPE_NOT_PROFILED = "not_profiled_cna"; +const CNA_TYPE_NO_DATA = "not_profiled_cna"; export interface IScatterPlotSampleData { uniqueSampleKey:string; sampleId:string; studyId:string; - dispCna?:NumericGeneMolecularData; + dispCna?:AnnotatedNumericGeneMolecularData; dispMutationType?:OncoprintMutationType; dispMutationSummary?:MutationSummary; profiledCna?:boolean; profiledMutations?:boolean; mutations: AnnotatedMutation[]; - copyNumberAlterations: NumericGeneMolecularData[]; + copyNumberAlterations: AnnotatedNumericGeneMolecularData[]; } export interface IScatterPlotData extends IScatterPlotSampleData, IBaseScatterPlotData {}; @@ -132,29 +134,50 @@ export function isNumberData(d:IAxisData): d is INumberAxisData { return d.datatype === "number"; } -export function sortScatterPlotDataForZIndex>( - data: D[], +export function scatterPlotZIndexSortBy>( viewType:ViewType, - highlight: (d:D)=>boolean + highlight?: (d:D)=>boolean ) { // sort by render priority + const sortByHighlight = highlight ? ((d:D)=>(highlight(d) ? 1 : 0)) : ((d:D)=>0); + + const sortByMutation = (d:D)=>{ + if (!d.profiledMutations) { + return -mutationRenderPriority[MUTATION_TYPE_NOT_PROFILED]; + } else if (!d.dispMutationType) { + return -mutationRenderPriority[MUTATION_TYPE_NOT_MUTATED]; + } else if (d.dispMutationType in mutationRenderPriority) { + return -mutationRenderPriority[d.dispMutationType!]; + } else { + return Number.NEGATIVE_INFINITY; + } + }; + + const sortByCna = (d:D)=>{ + if (!d.profiledCna) { + return -cnaRenderPriority[CNA_TYPE_NOT_PROFILED]; + } else if (!d.dispCna) { + return -cnaRenderPriority[CNA_TYPE_NO_DATA]; + } else if (d.dispCna.value in cnaRenderPriority) { + return -cnaRenderPriority[d.dispCna.value] + } else { + return Number.NEGATIVE_INFINITY; + } + }; + + let sortBy; switch (viewType) { case ViewType.MutationTypeAndCopyNumber: + sortBy = [sortByHighlight, sortByMutation, sortByCna]; + break; case ViewType.MutationType: - data = _.sortBy(data, d=>{ - if (d.dispMutationType! in mutationRenderPriority) { - return -mutationRenderPriority[d.dispMutationType!] - } else if (!d.dispMutationType) { - return -mutationRenderPriority[MUTATION_TYPE_NOT_MUTATED]; - } else if (!d.profiledMutations) { - return -mutationRenderPriority[MUTATION_TYPE_NOT_PROFILED]; - } else { - return Number.NEGATIVE_INFINITY; - } - }); + sortBy = [sortByHighlight, sortByMutation]; + break; + case ViewType.CopyNumber: + sortBy = [sortByHighlight, sortByCna]; break; case ViewType.MutationSummary: - data = _.sortBy(data, d=>{ + sortBy = [sortByHighlight, (d:D)=>{ if (d.dispMutationSummary! in mutationSummaryRenderPriority) { return -mutationSummaryRenderPriority[d.dispMutationSummary!] ; } else if (!d.profiledMutations) { @@ -162,20 +185,10 @@ export function sortScatterPlotDataForZIndex{ - const ret = x !== null; - if (!ret) { - showNoCnaElement = true; + if (x === null) { + showNotProfiledElement = true; } - return ret; + return x !== null; }) .sortBy((v:number)=>-v) // sorted descending .value(); @@ -360,17 +371,6 @@ function scatterPlotCnaLegendData( } }; }); - if (showNoCnaElement) { - legendData.push({ - name: noCnaAppearance.legendLabel, - symbol: { - stroke: noCnaAppearance.stroke, - fillOpacity: 0, - type: "circle", - strokeWidth: CNA_STROKE_WIDTH - } - }); - } if (showNotProfiledElement) { legendData.push({ name: NOT_PROFILED_CNA_LEGEND_LABEL, @@ -812,14 +812,12 @@ const cnaToAppearance = { } }; -const noCnaAppearance = { - stroke: "#333333", - strokeOpacity:1, - legendLabel: "No CNA data", -}; - const cnaCategoryOrder = ["-2", "-1", "0", "1", "2"].map(x=>(cnaToAppearance as any)[x].legendLabel); +export const cnaRenderPriority = stringListToIndexSet([ + "-2", "2", "-1", "1", "0", CNA_TYPE_NOT_PROFILED +]); + function getMutationTypeAppearance(d:IScatterPlotSampleData, oncoprintMutationTypeToAppearance:{[mutType:string]:{symbol:string, fill:string, stroke:string, strokeOpacity:number, legendLabel:string}}) { if (!d.profiledMutations) { return notProfiledAppearance; @@ -830,10 +828,8 @@ function getMutationTypeAppearance(d:IScatterPlotSampleData, oncoprintMutationTy } } function getCopyNumberAppearance(d:IScatterPlotSampleData) { - if (!d.profiledCna) { + if (!d.profiledCna || !d.dispCna) { return notProfiledAppearance; - } else if (!d.dispCna) { - return noCnaAppearance; } else { return cnaToAppearance[d.dispCna.value as -2 | -1 | 0 | 1 | 2]; } @@ -893,16 +889,15 @@ function mutationsProteinChanges( return sorted.map(entry=>`${entry[0]}: ${entry[1].filter(m=>!!m.proteinChange).map(m=>m.proteinChange).join(", ")}`); } -function tooltipMutationsSection( +export function tooltipMutationsSection( mutations:AnnotatedMutation[], - entrezGeneIdToGene:{[entrezGeneId:number]:Gene} ) { const oncoKbIcon = (mutation:AnnotatedMutation)=>(); const hotspotIcon = ; - const mutationsByGene = _.groupBy(mutations.filter(m=>!!m.proteinChange), m=>entrezGeneIdToGene[m.entrezGeneId].hugoGeneSymbol); + const mutationsByGene = _.groupBy(mutations.filter(m=>!!m.proteinChange), m=>m.hugoGeneSymbol); const sorted = _.chain(mutationsByGene).entries().sortBy(x=>x[0]).value(); return ( -
+ {sorted.map(entry=>{ const proteinChangeComponents = []; for (const mutation of entry[1]) { @@ -920,19 +915,53 @@ function tooltipMutationsSection( ); })} -
+ + ); +} + +export function tooltipCnaSection( + data:AnnotatedNumericGeneMolecularData[], +) { + const oncoKbIcon = (alt:AnnotatedNumericGeneMolecularData)=>(); + const altsByGene = _.groupBy(data, alt=>alt.hugoGeneSymbol); + const sorted = _.chain(altsByGene).entries().sortBy(x=>x[0]).value(); + return ( + + {sorted.map(entry=>{ + const alterationComponents = []; + for (const alt of entry[1]) { + if (alt.value in cnaToAppearance) { + alterationComponents.push( + + {(cnaToAppearance as any)[alt.value].legendLabel}{alt.oncoKbOncogenic ? oncoKbIcon(alt) : null} + + ); + alterationComponents.push(, ); + } + } + alterationComponents.pop(); // remove last comma + return ( + + {entry[0]}: {alterationComponents} + + ); + })} + ); } function generalScatterPlotTooltip( d:D, - entrezGeneIdToGene:MobxPromise<{[entrezGeneId:number]:Gene}>, horizontalKey:keyof D, verticalKey:keyof D ) { let mutationsSection:any = null; - if (entrezGeneIdToGene.isComplete && d.mutations.length) { - mutationsSection = tooltipMutationsSection(d.mutations, entrezGeneIdToGene.result!); + if (d.mutations.length > 0) { + mutationsSection = tooltipMutationsSection(d.mutations); + } + let cnaSection:any = null; + if (d.copyNumberAlterations.length > 0) { + cnaSection = tooltipCnaSection(d.copyNumberAlterations); } return (
@@ -940,20 +969,21 @@ function generalScatterPlotTooltip(
Horizontal: {d[horizontalKey] as any}
Vertical: {d[verticalKey] as any}
{mutationsSection} + {!!mutationsSection &&
} + {cnaSection}
); } -export function scatterPlotTooltip(d:IScatterPlotData, entrezGeneIdToGene:MobxPromise<{[entrezGeneId:number]:Gene}>) { - return generalScatterPlotTooltip(d, entrezGeneIdToGene, "x", "y"); +export function scatterPlotTooltip(d:IScatterPlotData) { + return generalScatterPlotTooltip(d, "x", "y"); } export function boxPlotTooltip( d:IBoxScatterPlotPoint, - entrezGeneIdToGene:MobxPromise<{[entrezGeneId:number]:Gene}>, horizontal:boolean ) { - return generalScatterPlotTooltip(d, entrezGeneIdToGene, horizontal ? "value" : "category", horizontal ? "category" : "value"); + return generalScatterPlotTooltip(d, horizontal ? "value" : "category", horizontal ? "category" : "value"); } export function logScalePossible( @@ -976,7 +1006,7 @@ export function makeBoxScatterPlotData( }, copyNumberAlterations?:{ molecularProfileIds:string[], - data:NumericGeneMolecularData[] + data:AnnotatedNumericGeneMolecularData[] } ):IBoxScatterPlotData[] { const boxScatterPlotPoints = makeScatterPlotData( @@ -1006,7 +1036,7 @@ export function makeScatterPlotData( }, copyNumberAlterations?:{ molecularProfileIds:string[], - data:NumericGeneMolecularData[] + data:AnnotatedNumericGeneMolecularData[] } ):IBoxScatterPlotPoint[]; @@ -1021,7 +1051,7 @@ export function makeScatterPlotData( }, copyNumberAlterations?:{ molecularProfileIds:string[], - data:NumericGeneMolecularData[] + data:AnnotatedNumericGeneMolecularData[] } ):IScatterPlotData[] @@ -1036,18 +1066,18 @@ export function makeScatterPlotData( }, copyNumberAlterations?:{ molecularProfileIds:string[], - data:NumericGeneMolecularData[] + data:AnnotatedNumericGeneMolecularData[] } ):IScatterPlotData[]|IBoxScatterPlotPoint[] { const mutationsMap:{[uniqueSampleKey:string]:AnnotatedMutation[]} = mutations ? _.groupBy(mutations.data, m=>m.uniqueSampleKey) : {}; - const cnaMap:{[uniqueSampleKey:string]:NumericGeneMolecularData[]} = + const cnaMap:{[uniqueSampleKey:string]:AnnotatedNumericGeneMolecularData[]} = copyNumberAlterations? _.groupBy(copyNumberAlterations.data, d=>d.uniqueSampleKey) : {}; const dataMap:{[uniqueSampleKey:string]:Partial} = {}; for (const d of horzData.data) { const sample = uniqueSampleKeyToSample[d.uniqueSampleKey]; - const sampleCopyNumberAlterations:NumericGeneMolecularData[] | undefined = cnaMap[d.uniqueSampleKey]; - let dispCna:NumericGeneMolecularData | undefined = undefined; + const sampleCopyNumberAlterations:AnnotatedNumericGeneMolecularData[] | undefined = cnaMap[d.uniqueSampleKey]; + let dispCna:AnnotatedNumericGeneMolecularData | undefined = undefined; if (sampleCopyNumberAlterations && sampleCopyNumberAlterations.length) { dispCna = sampleCopyNumberAlterations[0]; for (const alt of sampleCopyNumberAlterations) { @@ -1160,15 +1190,22 @@ function makeScatterPlotData_profiledReport( } export function getCnaQueries( - entrezGeneId:number, - studyToMolecularProfileDiscrete:{[studyId:string]:MolecularProfile}, + horzSelection:AxisMenuSelection, + vertSelection:AxisMenuSelection, cnaDataShown:boolean ) { if (!cnaDataShown) { return []; } - return _.values(studyToMolecularProfileDiscrete) - .map(p=>({molecularProfileId: p.molecularProfileId, entrezGeneId})); + const queries:{entrezGeneId:number}[] = []; + if (horzSelection.dataType !== CLIN_ATTR_DATA_TYPE && horzSelection.entrezGeneId !== undefined) { + queries.push({entrezGeneId: horzSelection.entrezGeneId}); + } + if (vertSelection.dataType !== CLIN_ATTR_DATA_TYPE && vertSelection.entrezGeneId !== undefined && + vertSelection.entrezGeneId !== horzSelection.entrezGeneId) { + queries.push({entrezGeneId: vertSelection.entrezGeneId}); + } + return queries; } export function getMutationQueries( diff --git a/src/shared/components/ChartContainer/ChartContainer.tsx b/src/shared/components/ChartContainer/ChartContainer.tsx index 0dc1de93dfe..cd86288debc 100644 --- a/src/shared/components/ChartContainer/ChartContainer.tsx +++ b/src/shared/components/ChartContainer/ChartContainer.tsx @@ -3,7 +3,7 @@ import autobind from "autobind-decorator"; import DownloadControls from "../downloadControls/DownloadControls"; interface IChartContainer { - getSVGElement?:()=>SVGElement; + getSVGElement?:()=>SVGElement|null; exportFileName?:string; } diff --git a/src/shared/components/plots/BoxScatterPlot.tsx b/src/shared/components/plots/BoxScatterPlot.tsx index c8b3b9dd920..2eb308ce6d0 100644 --- a/src/shared/components/plots/BoxScatterPlot.tsx +++ b/src/shared/components/plots/BoxScatterPlot.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import {observer} from "mobx-react"; +import {observer, Observer} from "mobx-react"; import {computed, observable} from "mobx"; import {bind} from "bind-decorator"; import CBIOPORTAL_VICTORY_THEME, {axisTickLabelStyles} from "../../theme/cBioPoralTheme"; @@ -9,11 +9,12 @@ import ScatterPlotTooltip from "./ScatterPlotTooltip"; import Timer = NodeJS.Timer; import {VictoryBoxPlot, VictoryChart, VictoryAxis, VictoryScatter, VictoryLegend, VictoryLabel} from "victory"; import {IBaseScatterPlotData} from "./ScatterPlot"; -import {getDeterministicRandomNumber} from "./PlotUtils"; +import {getDeterministicRandomNumber, separateScatterDataByAppearance} from "./PlotUtils"; import {logicalAnd} from "../../lib/LogicUtils"; import {tickFormatNumeral, wrapTick} from "./TickUtils"; import {scatterPlotSize} from "./PlotUtils"; import {getTextWidth} from "../../lib/wrapText"; +import autobind from "autobind-decorator"; export interface IBaseBoxScatterPlotPoint { value:number; @@ -27,10 +28,10 @@ export interface IBoxScatterPlotData { export interface IBoxScatterPlotProps { svgId?:string; - fontFamily?:string; title?:string; data: IBoxScatterPlotData[]; chartBase:number; + domainPadding?:number; // see https://formidable.com/open-source/victory/docs/victory-chart/#domainpadding highlight?:(d:D)=>boolean; size?:(d:D, active:boolean, isHighlighted?:boolean)=>number; fill?:string | ((d:D)=>string); @@ -38,9 +39,10 @@ export interface IBoxScatterPlotProps { fillOpacity?:number | ((d:D)=>number); strokeOpacity?:number | ((d:D)=>number); strokeWidth?:number | ((d:D)=>number); + zIndexSortBy?:((d:D)=>any)[]; // second argument to _.sortBy symbol?: string | ((d:D)=>string); // see http://formidable.com/open-source/victory/docs/victory-scatter/#symbol for options tooltip?:(d:D)=>JSX.Element; - legendData?:{name:string, symbol:any}[]; // see http://formidable.com/open-source/victory/docs/victory-legend/#data + legendData?:{name:string|string[], symbol:any}[]; // see http://formidable.com/open-source/victory/docs/victory-legend/#data logScale?:boolean; // log scale along the point data axis axisLabelX?:string; axisLabelY?:string; @@ -48,6 +50,7 @@ export interface IBoxScatterPlotProps { useLogSpaceTicks?:boolean; // if log scale for an axis, then this prop determines whether the ticks are shown in post-log coordinate, or original data coordinate space boxWidth?:number; legendLocationWidthThreshold?:number; // chart width after which we start putting the legend at the bottom of the plot + boxCalculationFilter?:(d:D)=>boolean; // determines which points are used for calculating the box } type BoxModel = { @@ -60,17 +63,16 @@ type BoxModel = { y?:number }; -const DEFAULT_FONT_FAMILY = "Verdana,Arial,sans-serif"; const RIGHT_GUTTER = 120; // room for legend const NUM_AXIS_TICKS = 8; const PLOT_DATA_PADDING_PIXELS = 100; const MIN_LOG_ARGUMENT = 0.01; -const CATEGORY_LABEL_HORZ_ANGLE = -30; +const CATEGORY_LABEL_HORZ_ANGLE = 50; const DEFAULT_LEFT_PADDING = 25; const DEFAULT_BOTTOM_PADDING = 10; -const MAXIMUM_CATEGORY_LABEL_SIZE = 120; const LEGEND_ITEMS_PER_ROW = 4; -const BOTTOM_LEGEND_PADDING = 30; +const BOTTOM_LEGEND_PADDING = 15; +const RIGHT_PADDING_FOR_LONG_LABELS = 50; const BOX_STYLES = { @@ -83,7 +85,7 @@ const BOX_STYLES = { @observer export default class BoxScatterPlot extends React.Component, {}> { - @observable tooltipModel:any|null = null; + @observable.ref tooltipModel:any|null = null; @observable pointHovered:boolean = false; private mouseEvents:any = this.makeMouseEvents(); @@ -141,17 +143,13 @@ export default class BoxScatterPlot extends }]; } - @computed get fontFamily() { - return this.props.fontFamily || DEFAULT_FONT_FAMILY; - } - private get title() { if (this.props.title) { return ( extends private get legend() { if (this.props.legendData && this.props.legendData.length) { + let legendData = this.props.legendData; + if (this.legendLocation === "bottom") { + // if legend is at bottom then flatten labels + legendData = legendData.map(x=>{ + let name = x.name; + if (Array.isArray(x.name)) { + name = (name as string[]).join(" "); // flatten labels by joining with space + } + return { + name, symbol: x.symbol + }; + }); + } return ( @@ -221,7 +232,7 @@ export default class BoxScatterPlot extends } @computed get plotDomain() { - // data extremes plus padding + // data extremes let max = Number.NEGATIVE_INFINITY; let min = Number.POSITIVE_INFINITY; for (const d of this.props.data) { @@ -247,12 +258,21 @@ export default class BoxScatterPlot extends return { x, y }; } + @computed get domainPadding() { + if (this.props.domainPadding === undefined) { + return PLOT_DATA_PADDING_PIXELS; + } else { + return this.props.domainPadding; + } + } + @computed get chartExtent() { - const miscPadding = 200; // specifying chart width in victory doesnt translate directly to the actual graph size + const miscPadding = 100; // specifying chart width in victory doesnt translate directly to the actual graph size const numBoxes = this.props.data.length; - const computedExtent = numBoxes*this.boxWidth + (numBoxes-1)*this.boxSeparation + miscPadding; - const ret = Math.max(computedExtent, this.props.chartBase); - return ret; + return this.categoryCoord(numBoxes - 1) + 2*this.domainPadding + miscPadding; + //return 2*this.domainPadding + numBoxes*this.boxWidth + (numBoxes-1)*this.boxSeparation; + //const ret = Math.max(computedExtent, this.props.chartBase); + //return ret; } @computed get svgWidth() { @@ -327,7 +347,8 @@ export default class BoxScatterPlot extends @bind private formatCategoryTick(t:number, index:number) { - return wrapTick(this.labels[index], MAXIMUM_CATEGORY_LABEL_SIZE); + //return wrapTick(this.labels[index], MAXIMUM_CATEGORY_LABEL_SIZE); + return this.labels[index]; } @bind @@ -350,7 +371,7 @@ export default class BoxScatterPlot extends tickFormat={this.props.horizontal ? this.formatNumericalTick : this.formatCategoryTick} tickLabelComponent={} axisLabelComponent={} /> @@ -376,7 +397,7 @@ export default class BoxScatterPlot extends @computed get scatterPlotData() { let dataAxis:"x"|"y" = this.props.horizontal ? "x" : "y"; let categoryAxis:"x"|"y" = this.props.horizontal ? "y" : "x"; - const data:{x:number, y:number}[] = []; + const data:(D&{x:number, y:number})[] = []; for (let i=0; i extends } as {x:number, y:number} & IBaseBoxScatterPlotPoint)); } } - return data; + return separateScatterDataByAppearance( + data, + ifndef(this.props.fill, "0x000000"), + ifndef(this.props.stroke, "0x000000"), + ifndef(this.props.strokeWidth, 0), + ifndef(this.props.strokeOpacity, 1), + ifndef(this.props.fillOpacity, 1), + this.props.zIndexSortBy + ); } @computed get leftPadding() { @@ -403,11 +432,11 @@ export default class BoxScatterPlot extends } @computed get rightPadding() { - if (this.legendLocation === "right") { + if (this.props.legendData && this.props.legendData.length > 0 && this.legendLocation === "right") { // make room for legend - return RIGHT_GUTTER; + return Math.max(RIGHT_GUTTER, RIGHT_PADDING_FOR_LONG_LABELS); } else { - return 0; + return RIGHT_PADDING_FOR_LONG_LABELS; } } @@ -428,16 +457,15 @@ export default class BoxScatterPlot extends } @computed get biggestCategoryLabelSize() { - const maxSize = Math.min( - Math.max(...this.labels.map(x=>getTextWidth(x, axisTickLabelStyles.fontFamily, axisTickLabelStyles.fontSize+"px"))), - MAXIMUM_CATEGORY_LABEL_SIZE + const maxSize = Math.max( + ...this.labels.map(x=>getTextWidth(x, axisTickLabelStyles.fontFamily, axisTickLabelStyles.fontSize+"px")) ); if (this.props.horizontal) { // if horizontal mode, its label width return maxSize; } else { // if vertical mode, its label height when rotated - return maxSize*Math.abs(Math.sin((Math.PI/180) * CATEGORY_LABEL_HORZ_ANGLE)) + return maxSize*Math.abs(Math.sin((Math.PI/180) * CATEGORY_LABEL_HORZ_ANGLE)); } } @@ -450,7 +478,7 @@ export default class BoxScatterPlot extends } private categoryCoord(index:number) { - return index * this.boxSeparation; + return index * (this.boxWidth + this.boxSeparation); // half box + separation + half box } @computed get categoryTickValues() { @@ -458,13 +486,18 @@ export default class BoxScatterPlot extends } @computed get boxPlotData():BoxModel[] { - return this.props.data.map(d=>calculateBoxPlotModel(d.data.map(x=>{ - if (this.props.logScale) { - return this.logScale(x.value); - } else { - return x.value; + const boxCalculationFilter = this.props.boxCalculationFilter; + return this.props.data.map(d=>calculateBoxPlotModel(d.data.reduce((data, next)=>{ + if (!boxCalculationFilter || (boxCalculationFilter && boxCalculationFilter(next))) { + // filter out values in calculating boxes, if a filter is specified ^^ + if (this.props.logScale) { + data.push(this.logScale(next.value)); + } else { + data.push(next.value); + } } - }))).map((model, i)=>{ + return data; + }, [] as number[]))).map((model, i)=>{ // create boxes, importantly we dont filter at this step because // we need the indexes to be intact and correpond to the index in the input data, // in order to properly determine the x/y coordinates @@ -491,72 +524,84 @@ export default class BoxScatterPlot extends }); } - - render() { - if (!this.props.data.length) { - return No data to plot.; - } + @autobind + private getChart() { return ( -
-
+ - - - - {this.title} - {this.legend} - {this.horzAxis} - {this.vertAxis} - + {this.title} + {this.legend} + {this.horzAxis} + {this.vertAxis} + + {this.scatterPlotData.map(dataWithAppearance=>( - - - -
+ ))} + + + +
+ ); + } + + + render() { + if (!this.props.data.length) { + return No data to plot.; + } + return ( +
+ + {this.getChart} + {this.container && this.tooltipModel && this.props.tooltip && ( ( return (active || !!(highlight && highlight(d)) ? 6 : 3); }; } +} + +export function separateScatterDataByAppearance( + 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), + zIndexSortBy?:((d:D)=>any)[] // second argument to _.sortBy +):{ + data:D[], + fill:string, + stroke:string, + strokeWidth:number, + strokeOpacity:number, + fillOpacity:number +}[] { + let buckets:{ + data:D[], + fill:string, + stroke:string, + strokeWidth:number, + strokeOpacity:number, + fillOpacity:number, + sortBy:any[] + }[] = []; + + let d_fill:string, d_stroke:string, d_strokeWidth:number, d_strokeOpacity:number, d_fillOpacity:number, + d_sortBy:any[], bucketFound:boolean; + + 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_sortBy = (zIndexSortBy ? zIndexSortBy.map(f=>f(datum)) : [1]); + + // look for existing bucket to put datum + bucketFound = false; + for (const bucket of buckets) { + if (bucket.fill === d_fill && bucket.stroke === d_stroke && bucket.strokeWidth === d_strokeWidth && + bucket.strokeOpacity === d_strokeOpacity && bucket.fillOpacity === d_fillOpacity && + _.isEqual(bucket.sortBy, d_sortBy)) { + // if bucket with matching appearance exists, add to bucket + bucket.data.push(datum); + // mark bucket has been found so we dont need to add a bucket + bucketFound = true; + break; + } + } + if (!bucketFound) { + // if no bucket found, add bucket, and put datum in it + buckets.push({ + data: [datum], + fill: d_fill, stroke: d_stroke, strokeWidth: d_strokeWidth, + strokeOpacity: d_strokeOpacity, fillOpacity: d_fillOpacity, + 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; } \ No newline at end of file diff --git a/src/shared/components/plots/ScatterPlot.tsx b/src/shared/components/plots/ScatterPlot.tsx index e3f9f55fd42..bc930e08efb 100644 --- a/src/shared/components/plots/ScatterPlot.tsx +++ b/src/shared/components/plots/ScatterPlot.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import {observer} from "mobx-react"; +import {observer, Observer} from "mobx-react"; import bind from "bind-decorator"; import {computed, observable} from "mobx"; import CBIOPORTAL_VICTORY_THEME, {baseLabelStyles} from "../../theme/cBioPoralTheme"; @@ -9,7 +9,7 @@ import jStat from "jStat"; import ScatterPlotTooltip from "./ScatterPlotTooltip"; import ifndef from "shared/lib/ifndef"; import {tickFormatNumeral} from "./TickUtils"; -import {scatterPlotSize} from "./PlotUtils"; +import {scatterPlotSize, separateScatterDataByAppearance} from "./PlotUtils"; export interface IBaseScatterPlotData { x:number; @@ -29,6 +29,7 @@ export interface IScatterPlotProps { fillOpacity?:number | ((d:D)=>number); strokeOpacity?:number | ((d:D)=>number); strokeWidth?:number | ((d:D)=>number); + zIndexSortBy?:((d:D)=>any)[]; // second argument to _.sortBy symbol?: string | ((d:D)=>string); // see http://formidable.com/open-source/victory/docs/victory-scatter/#symbol for options tooltip?:(d:D)=>JSX.Element; legendData?:{name:string|string[], symbol:any}[]; // see http://formidable.com/open-source/victory/docs/victory-legend/#data @@ -47,7 +48,7 @@ export interface IScatterPlotProps { const DEFAULT_FONT_FAMILY = "Verdana,Arial,sans-serif"; const CORRELATION_INFO_Y = 100; // experimentally determined export const LEGEND_Y = CORRELATION_INFO_Y + 30 /* approximate correlation info height */ + 30 /* top padding*/ -const RIGHT_GUTTER = 120; // room for correlation info and legend +const RIGHT_PADDING = 120; // room for correlation info and legend const NUM_AXIS_TICKS = 8; const PLOT_DATA_PADDING_PIXELS = 50; const MIN_LOG_ARGUMENT = 0.01; @@ -56,7 +57,7 @@ const LEFT_PADDING = 25; @observer export default class ScatterPlot extends React.Component, {}> { - @observable tooltipModel:any|null = null; + @observable.ref tooltipModel:any|null = null; @observable pointHovered:boolean = false; private mouseEvents:any = this.makeMouseEvents(); @@ -152,7 +153,7 @@ export default class ScatterPlot extends React.C data={this.props.legendData} x={x} y={LEGEND_Y} - width={RIGHT_GUTTER} + width={RIGHT_PADDING} /> ); } else { @@ -233,8 +234,12 @@ export default class ScatterPlot extends React.C return jStat.spearmancoeff(this.splitData.x, this.splitData.y); } + @computed get rightPadding() { + return RIGHT_PADDING; + } + @computed get svgWidth() { - return LEFT_PADDING + this.props.chartWidth + RIGHT_GUTTER; + return LEFT_PADDING + this.props.chartWidth + this.rightPadding; } @computed get svgHeight() { @@ -292,84 +297,109 @@ export default class ScatterPlot extends React.C return this.tickFormat(t, ticks, !!this.props.logY); } - render() { - if (!this.props.data.length) { - return No data to plot.; - } + @computed get data() { + return separateScatterDataByAppearance( + this.props.data, + ifndef(this.props.fill, "0x000000"), + ifndef(this.props.stroke, "0x000000"), + ifndef(this.props.strokeWidth, 0), + ifndef(this.props.strokeOpacity, 1), + ifndef(this.props.fillOpacity, 1), + this.props.zIndexSortBy + ); + } + + + @bind + private getChart() { return ( -
-
+ - - - - {this.title} - {this.legend} - } - label={this.props.axisLabelX} - /> - } - label={this.props.axisLabelY} - /> + {this.title} + {this.legend} + } + label={this.props.axisLabelX} + /> + } + label={this.props.axisLabelY} + /> + { this.data.map(dataWithAppearance=>( - - {this.correlationInfo} - - -
+ ))} + + {this.correlationInfo} + + +
+ ); + } + + render() { + if (!this.props.data.length) { + return No data to plot.; + } + return ( +
+ + {this.getChart} + {this.container && this.tooltipModel && this.props.tooltip && (