diff --git a/packages/cbioportal-clinical-timeline/src/Timeline.tsx b/packages/cbioportal-clinical-timeline/src/Timeline.tsx index dee6b5d2f7e..bbcb12b5a76 100644 --- a/packages/cbioportal-clinical-timeline/src/Timeline.tsx +++ b/packages/cbioportal-clinical-timeline/src/Timeline.tsx @@ -27,11 +27,15 @@ import { DownloadControls } from 'cbioportal-frontend-commons'; import CustomTrack, { CustomTrackSpecification } from './CustomTrack'; import CustomTrackHeader from './CustomTrackHeader'; import { TIMELINE_TRACK_HEIGHT } from './TimelineTrack'; +import classNames from 'classnames'; interface ITimelineProps { store: TimelineStore; customTracks?: CustomTrackSpecification[]; width: number; + hideLabels?: boolean; + visibleTrackTypes?: string[]; + disableTrackHover?: boolean; } const getFocusedPoints = _.debounce(function( @@ -235,8 +239,15 @@ const Timeline: React.FunctionComponent = observer(function({ store, customTracks, width, + hideLabels = false, + visibleTrackTypes, + disableTrackHover, }: ITimelineProps) { - const expandedTracks = expandTracks(store.data); + const expandedTracks = expandTracks( + store.data, + undefined, + visibleTrackTypes + ); const height = TICK_AXIS_HEIGHT + _.sumBy(expandedTracks, t => t.height) + @@ -256,7 +267,7 @@ const Timeline: React.FunctionComponent = observer(function({ const memoizedHoverCallback = useCallback( (e: React.MouseEvent) => { - hoverCallback(e, refs.hoverStyleTag); + if (!disableTrackHover) hoverCallback(e, refs.hoverStyleTag); }, [refs.hoverStyleTag] ); @@ -307,7 +318,11 @@ const Timeline: React.FunctionComponent = observer(function({ className={'tl-timeline-leftbar'} style={{ paddingTop: TICK_AXIS_HEIGHT, flexShrink: 0 }} > -
+
{expandedTracks.map(track => { return ( = observer(function({ width={renderWidth} customTracks={customTracks} handleTrackHover={memoizedHoverCallback} + visibleTrackTypes={visibleTrackTypes} /> | null = null; + @observable onlyShowSelectedInVAFChart: + | Readonly + | undefined = undefined; + @observable vafChartLogScale: Readonly | undefined = undefined; + @observable vafChartYAxisToDataRange: + | Readonly + | undefined = undefined; + @observable vafChartHeight: Readonly = 240; @observable mousePosition = { x: 0, y: 0 }; + @action + setVafChartHeight(value: number) { + this.vafChartHeight = value; + } + + @action + setGroupByOption(value: string) { + this.groupByOption = value; + } + + @action + setOnlyShowSelectedInVAFChart(value: boolean) { + this.onlyShowSelectedInVAFChart = value; + } + + @action + setVafChartLogScale(value: boolean) { + this.vafChartLogScale = value; + } + + @action + setVafChartYAxisToDataRange(value: boolean) { + this.vafChartYAxisToDataRange = value; + } + + @computed get xPositionBySampleId(): { [sampleId: string]: number } { + let positionList: { [sampleId: string]: number } = {}; + const samples = this.allItems.filter( + event => event.event.eventType === 'SPECIMEN' + ); + samples.forEach((sample, i) => { + sample.event.attributes.forEach((attribute: any, i: number) => { + if (attribute.key === 'SAMPLE_ID') { + positionList[attribute.value] = this.getPosition( + sample + )!.pixelLeft; + } + }); + }); + return positionList; + } + @autobind @action setTooltipModel( diff --git a/packages/cbioportal-clinical-timeline/src/TimelineTrack.tsx b/packages/cbioportal-clinical-timeline/src/TimelineTrack.tsx index 7c2b55ce397..bc66587ea77 100644 --- a/packages/cbioportal-clinical-timeline/src/TimelineTrack.tsx +++ b/packages/cbioportal-clinical-timeline/src/TimelineTrack.tsx @@ -8,6 +8,7 @@ import React from 'react'; import _ from 'lodash'; import { formatDate, REMOVE_FOR_DOWNLOAD_CLASSNAME } from './lib/helpers'; import { TimelineStore } from './TimelineStore'; +import { renderStack } from './svg/renderStack'; export interface ITimelineTrackProps { trackData: TimelineTrackSpecification; @@ -103,7 +104,7 @@ function renderSuperscript(number: number) { ); } -function renderLineChartLine(points: { x: number; y: number }[]) { +function renderLineChartLines(points: { x: number; y: number }[]) { if (points.length < 2) { return null; } @@ -170,7 +171,11 @@ export function renderPoint( return ( {events.length > 1 && renderSuperscript(events.length)} - + {events.length > 1 ? ( + renderStack(10, TIMELINE_TRACK_HEIGHT / 2, '#222222') + ) : ( + + )} ); } @@ -289,7 +294,8 @@ export const TimelineTrack: React.FunctionComponent< store.setTooltipModel(null); }} /> - {renderLineChartLine(linePoints)} + {trackData.trackType === TimelineTrackType.LINE_CHART && + renderLineChartLines(linePoints)} {points} ) => void; customTracks?: CustomTrackSpecification[]; + visibleTrackTypes?: string[]; } export const TimelineTracks: React.FunctionComponent< ITimelineTracks -> = observer(function({ store, width, handleTrackHover, customTracks }) { - const tracks = expandTracks(store.data); +> = observer(function({ + store, + width, + handleTrackHover, + customTracks, + visibleTrackTypes, +}) { + const tracks = expandTracks(store.data, undefined, visibleTrackTypes); let nextY = 0; @@ -65,18 +72,31 @@ export const TimelineTracks: React.FunctionComponent< ); })} - {store.tooltipContent && ( - - - {store.tooltipContent} - - - )} + {store.tooltipContent && + (() => { + const placementLeft = store.mousePosition.x > width / 2; + return ( + + + {store.tooltipContent} + + + ); + })()} ); }); diff --git a/packages/cbioportal-clinical-timeline/src/TrackHeader.tsx b/packages/cbioportal-clinical-timeline/src/TrackHeader.tsx index 39ee5550ae9..0676c343b24 100644 --- a/packages/cbioportal-clinical-timeline/src/TrackHeader.tsx +++ b/packages/cbioportal-clinical-timeline/src/TrackHeader.tsx @@ -9,7 +9,7 @@ import { TimelineStore } from './TimelineStore'; interface ITrackHeaderProps { track: TimelineTrackSpecification; - handleTrackHover: (e: React.MouseEvent) => void; + handleTrackHover?: (e: React.MouseEvent) => void; height: number; paddingLeft?: number; } @@ -57,13 +57,20 @@ function expandTrack( export function expandTracks( tracks: TimelineTrackSpecification[], - leftPadding: number | undefined = 5 + leftPadding: number | undefined = 5, + visibleTrackTypes?: string[] ) { - return _.flatMap(tracks, t => expandTrack(t, leftPadding)); + const flattened = _.flatMap(tracks, t => expandTrack(t, leftPadding)); + + if (visibleTrackTypes) { + return flattened.filter(t => visibleTrackTypes.includes(t.track.type)); + } else { + return flattened; + } } export const EXPORT_TRACK_HEADER_STYLE = - 'font-size: 12px;text-transform: capitalize; font-family:Arial'; + 'font-size: 12px;text-transform: uppercase; font-family:Arial'; export const EXPORT_TRACK_HEADER_BORDER_CLASSNAME = 'track-header-border'; export function getTrackHeadersG( diff --git a/packages/cbioportal-clinical-timeline/src/svg/renderStack.tsx b/packages/cbioportal-clinical-timeline/src/svg/renderStack.tsx new file mode 100644 index 00000000000..d9da0466558 --- /dev/null +++ b/packages/cbioportal-clinical-timeline/src/svg/renderStack.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +const SHEET_HEIGHT_OVER_WIDTH = 0.68; + +function renderCenteredSheet(width:number, y:number, fill:string, strokeWidth=0) { + const left = -width/2; + const right = width/2; + const top = SHEET_HEIGHT_OVER_WIDTH * left; + const bottom = SHEET_HEIGHT_OVER_WIDTH * right; + + return ( + + ); +} + +function renderMaskedSheet(width:number, y:number, fill:string) { + const maskProportion = 0.81; + const height = SHEET_HEIGHT_OVER_WIDTH * width; + return ( + <> + {renderCenteredSheet(width, y, fill)} + + {renderCenteredSheet(maskProportion * width, y, "#fff", 0.3)} + + + ); +} + +export function renderStack(width:number, y:number, fills:string|string[]) { + fills = ([] as string[]).concat(fills); + + // ensure 3 fills + if (fills.length === 1) { + fills = [fills[0], fills[0], fills[0]]; + } else if (fills.length === 2) { + fills = [fills[0], fills[1], fills[0]]; + } + + return ( + + {renderMaskedSheet(width, y + width/4.5, fills[0])} + {renderMaskedSheet(width, y, fills[1])} + {renderCenteredSheet(width, y - width/4.5, fills[2])} + + ) +} diff --git a/packages/cbioportal-clinical-timeline/src/timeline.scss b/packages/cbioportal-clinical-timeline/src/timeline.scss index 8e72fd89afb..759876f5042 100644 --- a/packages/cbioportal-clinical-timeline/src/timeline.scss +++ b/packages/cbioportal-clinical-timeline/src/timeline.scss @@ -71,7 +71,7 @@ $borderColor: #ccc; .tl-timeline-leftbar { .tl-timeline-tracklabels { font-size: 12px; - text-transform: capitalize; + text-transform: uppercase; > div { white-space: nowrap; border-bottom: 1px dashed #eee; @@ -130,3 +130,7 @@ $borderColor: #ccc; .timeline-label:last-of-type { display: none; } + +.tl-displaynone { + display: none; +} diff --git a/src/globalStyles/global.scss b/src/globalStyles/global.scss index b4c77f7e6e2..c3e7a11e5c8 100755 --- a/src/globalStyles/global.scss +++ b/src/globalStyles/global.scss @@ -631,3 +631,19 @@ h6.blackHeader { .nowrap { white-space: nowrap; } + +.standardMarginTop { + margin-top: $standardMargin; +} + +.standardMarginBottom { + margin-bottom: $standardMargin; +} + +.standardMarginLeft { + margin-left: $standardMargin; +} + +.standardMarginRight { + margin-right: $standardMargin; +} diff --git a/src/pages/patientView/PatientViewPage.tsx b/src/pages/patientView/PatientViewPage.tsx index 7dd5b4d6d37..dc5be20e930 100644 --- a/src/pages/patientView/PatientViewPage.tsx +++ b/src/pages/patientView/PatientViewPage.tsx @@ -36,6 +36,7 @@ import { MSKTab, MSKTabs } from '../../shared/components/MSKTabs/MSKTabs'; import { validateParametersPatientView } from '../../shared/lib/validateParameters'; import LoadingIndicator from 'shared/components/loadingIndicator/LoadingIndicator'; import ValidationAlert from 'shared/components/ValidationAlert'; +import PatientViewMutationsDataStore from './mutation/PatientViewMutationsDataStore'; import AppConfig from 'appConfig'; import { getMouseIcon } from './SVGIcons'; @@ -79,6 +80,8 @@ import ResourcesTab, { RESOURCES_TAB_NAME } from './resources/ResourcesTab'; import { MakeMobxView } from '../../shared/components/MobxView'; import ResourceTab from '../../shared/components/resources/ResourceTab'; import TimelineWrapper from './timeline2/TimelineWrapper'; +import { isFusion } from '../../shared/lib/MutationUtils'; +import { Mutation } from 'cbioportal-ts-api-client'; export interface IPatientViewPageProps { params: any; // react route @@ -202,6 +205,20 @@ export default class PatientViewPage extends React.Component< ); } + private dataStore = new PatientViewMutationsDataStore( + () => this.mergedMutations, + this.urlWrapper + ); + + @computed get mergedMutations() { + // remove fusions + return this.patientViewPageStore.mergedMutationDataIncludingUncalledFilteredByGene.filter( + mutationArray => { + return !isFusion(mutationArray[0]); + } + ); + } + componentDidMount() { // Load posted data, if it exists const postData = getBrowserWindow().clientPostedData; @@ -447,6 +464,23 @@ export default class PatientViewPage extends React.Component< } } + @autobind + private onTableRowClick(d: Mutation[]) { + if (d.length) { + this.dataStore.toggleSelectedMutation(d[0]); + } + } + @autobind + private onTableRowMouseEnter(d: Mutation[]) { + if (d.length) { + this.dataStore.setMouseOverMutation(d[0]); + } + } + @autobind + private onTableRowMouseLeave() { + this.dataStore.setMouseOverMutation(null); + } + readonly resourceTabs = MakeMobxView({ await: () => [ this.patientViewPageStore.resourceDefinitions, @@ -888,6 +922,9 @@ export default class PatientViewPage extends React.Component< }} > -
- -
-
+ {/*
*/} + {/* */} + {/*
*/} +
)} @@ -1252,6 +1306,17 @@ export default class PatientViewPage extends React.Component< .patientViewPageStore .generateGenomeNexusHgvsgUrl } + onRowClick={ + this.onTableRowClick + } + onRowMouseEnter={ + this + .onTableRowMouseEnter + } + onRowMouseLeave={ + this + .onTableRowMouseLeave + } /> )} diff --git a/src/pages/patientView/SampleManager.tsx b/src/pages/patientView/SampleManager.tsx index d512d0dae17..aa584160a95 100644 --- a/src/pages/patientView/SampleManager.tsx +++ b/src/pages/patientView/SampleManager.tsx @@ -1,12 +1,17 @@ import * as React from 'react'; import * as _ from 'lodash'; import SampleInline from './patientHeader/SampleInline'; -import { ClinicalData, ClinicalDataBySampleId } from 'cbioportal-ts-api-client'; +import { + ClinicalData, + ClinicalDataBySampleId, + ClinicalAttribute, +} from 'cbioportal-ts-api-client'; import { cleanAndDerive } from './clinicalInformation/lib/clinicalAttributesUtil.js'; import styles from './patientHeader/style/clinicalAttributes.scss'; import naturalSort from 'javascript-natural-sort'; import { ClinicalEvent, ClinicalEventData } from 'cbioportal-ts-api-client'; import { SampleLabelHTML } from 'shared/components/sampleLabel/SampleLabel'; +import { computed } from 'mobx'; // sort samples based on event, clinical data and id // 1. based on sample collection data (timeline event) @@ -269,6 +274,14 @@ class SampleManager { return this.samples.map((sample: ClinicalDataBySampleId) => sample.id); } + @computed get sampleIdToIndexMap() { + let indexMap: { [sampleId: string]: number } = {}; + this.samples.forEach((sample, index) => { + indexMap[sample.id] = index; + }); + return indexMap; + } + getComponentsForSamples() { this.samples.map(sample => this.getComponentForSample(sample.id)); } @@ -279,6 +292,53 @@ class SampleManager { } return ''; } + + getClinicalAttributeList(samples: Array) { + let clinicalAttributeIds: { [id: string]: ClinicalAttribute } = {}; + let clinicalAttributes: { id: string; value: string }[] = []; + samples.forEach((sample, sampleIndex) => { + sample.clinicalData.forEach((clinicalData, clinicalDataIndex) => { + if ( + clinicalAttributeIds[clinicalData.clinicalAttributeId] === + undefined + ) { + clinicalAttributes.push({ + id: clinicalData.clinicalAttributeId, + value: clinicalData.clinicalAttribute.displayName, + }); + clinicalAttributeIds[clinicalData.clinicalAttributeId] = + clinicalData.clinicalAttribute; + } + }); + }); + return clinicalAttributes; + } + + getClinicalAttributeSampleList( + samples: Array, + clinicalAttributeId: string + ) { + let clinicalAttributeSamplesMap = new Map(); + if (clinicalAttributeId === undefined) return []; + + samples.forEach((sample, sampleIndex) => { + sample.clinicalData.forEach((clinicalData, clinicalDataIndex) => { + if (clinicalData.clinicalAttributeId === clinicalAttributeId) { + let sampleList = clinicalAttributeSamplesMap.get( + clinicalData.value + ); + if (sampleList === undefined) sampleList = []; + sampleList.push(sample.id); + + clinicalAttributeSamplesMap.set( + clinicalData.value, + sampleList + ); + } + }); + }); + return clinicalAttributeSamplesMap; + } } export default SampleManager; diff --git a/src/pages/patientView/mutation/PatientViewMutationsDataStore.ts b/src/pages/patientView/mutation/PatientViewMutationsDataStore.ts index 70e67715195..fbd9c8c5595 100644 --- a/src/pages/patientView/mutation/PatientViewMutationsDataStore.ts +++ b/src/pages/patientView/mutation/PatientViewMutationsDataStore.ts @@ -3,6 +3,7 @@ import { Mutation } from 'cbioportal-ts-api-client'; import { action, computed, observable } from 'mobx'; import _ from 'lodash'; import PatientViewUrlWrapper from '../PatientViewUrlWrapper'; +import { MutationStatus } from '../mutation/PatientViewMutationsTabUtils'; function mutationMatch(d: Mutation[], id: Mutation) { return ( @@ -18,7 +19,24 @@ function mutationIdKey(m: Mutation) { export default class PatientViewMutationsDataStore extends SimpleGetterLazyMobXTableApplicationDataStore< Mutation[] > { - @observable.ref private mouseOverMutation: Readonly | null = null; + @observable tooltipModel: { + datum: { + mutationStatus: MutationStatus | null; + sampleId: string; + vaf: number; + } | null; + mutation: Mutation | null; + mouseEvent: React.MouseEvent | null; + tooltipOnPoint: boolean; + } = { + datum: null, + mutation: null, + mouseEvent: null, + tooltipOnPoint: false, + }; + + @observable private mouseOverMutation: Readonly | null = null; + private selectedMutationsMap = observable.map(); public getMouseOverMutation() { @@ -42,6 +60,26 @@ export default class PatientViewMutationsDataStore extends SimpleGetterLazyMobXT @action public setMouseOverMutation(m: Readonly | null) { this.mouseOverMutation = m; + this.tooltipModel.mutation = m; + } + + @action + public setTooltipModel( + datum: { + sampleId: string; + vaf: number; + mutationStatus: MutationStatus; + } | null, + mutation: Mutation | null, + mouseEvent: React.MouseEvent, + tooltipOnPoint: boolean + ) { + this.tooltipModel.mouseEvent = mouseEvent; + this.tooltipModel.mutation = mutation; + this.tooltipModel.datum = datum; + this.tooltipModel.tooltipOnPoint = tooltipOnPoint; + // mouseOverMutation is used to higlight mutations in the mutation table below + this.mouseOverMutation = mutation; } @action diff --git a/src/pages/patientView/mutation/VAFLineChart.tsx b/src/pages/patientView/mutation/VAFLineChart.tsx index 6f0010fc92d..3b17c317523 100644 --- a/src/pages/patientView/mutation/VAFLineChart.tsx +++ b/src/pages/patientView/mutation/VAFLineChart.tsx @@ -32,6 +32,7 @@ import invertIncreasingFunction, { import { mutationTooltip } from './PatientViewMutationsTabUtils'; import { tickFormatNumeral } from '../../../shared/components/plots/TickUtils'; import { computeRenderData, IPoint } from './VAFLineChartUtils'; +import { GROUP_BY_NONE } from '../timeline2/VAFChartControls'; export interface IVAFLineChartProps { samples: Sample[]; @@ -326,7 +327,9 @@ export default class VAFLineChart extends React.Component< this.mutations, this.sampleIdIndex, this.props.mutationProfileId, - this.props.coverageInformation + this.props.coverageInformation, + GROUP_BY_NONE, + {} ); } diff --git a/src/pages/patientView/mutation/VAFLineChartUtils.spec.ts b/src/pages/patientView/mutation/VAFLineChartUtils.spec.ts index 46c3cf58d54..ff93156f745 100644 --- a/src/pages/patientView/mutation/VAFLineChartUtils.spec.ts +++ b/src/pages/patientView/mutation/VAFLineChartUtils.spec.ts @@ -7,6 +7,7 @@ import { makeMutationHeatmapData } from './oncoprint/MutationOncoprintUtils'; import { MutationOncoprintMode } from './oncoprint/MutationOncoprint'; import { assertDeepEqualInAnyOrder } from '../../../shared/lib/SpecUtils'; import { computeRenderData, IPoint } from './VAFLineChartUtils'; +import { GROUP_BY_NONE } from '../timeline2/VAFChartControls'; import _ from 'lodash'; describe('VAFLineChartUtils', () => { @@ -160,7 +161,9 @@ describe('VAFLineChartUtils', () => { [], sampleIdIndex, 'mutations', - makeCoverageInfo([1, 2, 3], []) + makeCoverageInfo([1, 2, 3], []), + GROUP_BY_NONE, + {} ), { grayPoints: [], @@ -199,7 +202,9 @@ describe('VAFLineChartUtils', () => { ], sampleIdIndex, 'mutations', - makeCoverageInfo([1, 2, 3], []) + makeCoverageInfo([1, 2, 3], []), + GROUP_BY_NONE, + {} ), { grayPoints: [], @@ -349,7 +354,9 @@ describe('VAFLineChartUtils', () => { ], sampleIdIndex, 'mutations', - makeCoverageInfo([1, 2, 3], []) + makeCoverageInfo([1, 2, 3], []), + GROUP_BY_NONE, + {} ), { grayPoints: [ @@ -482,7 +489,9 @@ describe('VAFLineChartUtils', () => { ], sampleIdIndex, 'mutations', - makeCoverageInfo([1, 2, 3], []) + makeCoverageInfo([1, 2, 3], []), + GROUP_BY_NONE, + {} ), { grayPoints: [ @@ -576,7 +585,9 @@ describe('VAFLineChartUtils', () => { ], sampleIdIndex, 'mutations', - makeCoverageInfo([1, 2, 3], []) + makeCoverageInfo([1, 2, 3], []), + GROUP_BY_NONE, + {} ), { grayPoints: [], @@ -699,7 +710,9 @@ describe('VAFLineChartUtils', () => { }, }, ] - ) + ), + GROUP_BY_NONE, + {} ), { grayPoints: [ @@ -794,7 +807,9 @@ describe('VAFLineChartUtils', () => { ], sampleIdIndex, 'mutations', - makeCoverageInfo([1, 2], [3]) + makeCoverageInfo([1, 2], [3]), + GROUP_BY_NONE, + {} ), { grayPoints: [], diff --git a/src/pages/patientView/mutation/VAFLineChartUtils.ts b/src/pages/patientView/mutation/VAFLineChartUtils.ts index 35db22abbcc..09ecaa84af7 100644 --- a/src/pages/patientView/mutation/VAFLineChartUtils.ts +++ b/src/pages/patientView/mutation/VAFLineChartUtils.ts @@ -4,6 +4,7 @@ import { isSampleProfiled } from '../../../shared/lib/isSampleProfiled'; import _ from 'lodash'; import { Mutation, Sample } from 'cbioportal-ts-api-client'; import { CoverageInformation } from '../../resultsView/ResultsViewPageStoreUtils'; +import { GROUP_BY_NONE } from '../timeline2/VAFChartControls'; export interface IPoint { x: number; @@ -20,16 +21,43 @@ function isPointBasedOnRealVAF(d: { mutationStatus: MutationStatus }) { ); } +function splitMutationsBySampleGroup( + mutations: Mutation[][], + sampleGroup: { [s: string]: string } +) { + let groupedMutation: { [s: string]: Mutation[] } = {}; + let groupedMutations: Mutation[][] = []; + + mutations.forEach((mutation, i) => { + groupedMutation = {}; + mutation.forEach((sample, j) => { + if (groupedMutation[sampleGroup[sample.sampleId]] === undefined) { + groupedMutation[sampleGroup[sample.sampleId]] = []; + } + groupedMutation[sampleGroup[sample.sampleId]].push(sample); + }); + for (const m in groupedMutation) { + groupedMutations.push(groupedMutation[m]); + } + }); + return groupedMutations; +} + export function computeRenderData( samples: Sample[], mutations: Mutation[][], sampleIdIndex: { [sampleId: string]: number }, mutationProfileId: string, - coverageInformation: CoverageInformation + coverageInformation: CoverageInformation, + groupByOption: string, + sampleGroup: { [s: string]: string } ) { const grayPoints: IPoint[] = []; // points that are purely interpolated for rendering, dont have data of their own const lineData: IPoint[][] = []; + if (groupByOption && groupByOption != GROUP_BY_NONE) + mutations = splitMutationsBySampleGroup(mutations, sampleGroup); + for (const mergedMutation of mutations) { // determine data points in line for this mutation diff --git a/src/pages/patientView/timeline2/TimelineWrapper.tsx b/src/pages/patientView/timeline2/TimelineWrapper.tsx index c971b112970..db9b13e2762 100644 --- a/src/pages/patientView/timeline2/TimelineWrapper.tsx +++ b/src/pages/patientView/timeline2/TimelineWrapper.tsx @@ -1,6 +1,11 @@ import React, { useEffect, useState } from 'react'; import _ from 'lodash'; import { observer } from 'mobx-react-lite'; +import { CoverageInformation } from '../../resultsView/ResultsViewPageStoreUtils'; +import { Sample } from 'cbioportal-ts-api-client'; +import PatientViewMutationsDataStore from '../mutation/PatientViewMutationsDataStore'; +import VAFChartControls from './VAFChartControls'; +import VAFChartWrapper from 'pages/patientView/timeline2/VAFChartWrapper'; import 'cbioportal-clinical-timeline/dist/styles.css'; @@ -32,6 +37,8 @@ function makeItems(eventData: ClinicalEvent[]) { }); } +const OTHER = 'Other'; + function organizeDataIntoTracks( rootTrackType: string, trackStructure: string[], @@ -41,7 +48,7 @@ function organizeDataIntoTracks( const rootData = item.attributes.find( (att: any) => att.key === trackStructure[0] ); - return rootData ? rootData.value : 'Other'; + return rootData ? rootData.value : OTHER; }); const tracks: TimelineTrackSpecification[] = _.map( @@ -75,6 +82,32 @@ function organizeDataIntoTracks( return track; } +function collapseOTHERTracks(rootTrack: TimelineTrackSpecification) { + // In-place operation modifying the input. + + // Recursively find cases where there is only one child track, an Other, + // and absorb its items and descendents into the parent. + // If rootTrack only has one child track and it is an OTHER track, then + // absorb its items and descendants into rootTrack. Keep going until + // this is no longer true. + while ( + rootTrack.tracks && + rootTrack.tracks.length === 1 && + rootTrack.tracks[0].type === OTHER + ) { + rootTrack.items = rootTrack.items.concat(rootTrack.tracks[0].items); + rootTrack.tracks = rootTrack.tracks[0].tracks; + } + + // Recurse + if (rootTrack.tracks) { + rootTrack.tracks.forEach(collapseOTHERTracks); + } + + // Finally, return the (possibly modified) argument for easy chaining + return rootTrack; +} + export interface ISampleMetaDeta { color: { [sampleId: string]: string }; index: { [sampleId: string]: number }; @@ -82,19 +115,32 @@ export interface ISampleMetaDeta { } export interface ITimeline2Props { + dataStore: PatientViewMutationsDataStore; data: ClinicalEvent[]; caseMetaData: ISampleMetaDeta; sampleManager: SampleManager; width: number; + samples: Sample[]; + mutationProfileId: string; + coverageInformation: CoverageInformation; } const TimelineWrapper: React.FunctionComponent = observer( - function({ data, caseMetaData, sampleManager, width }: ITimeline2Props) { + function({ + dataStore, + data, + caseMetaData, + sampleManager, + width, + samples, + mutationProfileId, + coverageInformation, + }: ITimeline2Props) { const [events, setEvents] = useState< TimelineTrackSpecification[] | null >(null); - const [store, setStore] = useState(null); + const [stores, setStores] = useState(null); useEffect(() => { var isGenieBpcStudy = window.location.href.includes('genie_bpc'); @@ -393,10 +439,12 @@ const TimelineWrapper: React.FunctionComponent = observer( if (trackKey in trackStructuresByRoot) { specs.push( - organizeDataIntoTracks( - trackKey, - trackStructuresByRoot[trackKey].slice(1), - data + collapseOTHERTracks( + organizeDataIntoTracks( + trackKey, + trackStructuresByRoot[trackKey].slice(1), + data + ) ) ); } else { @@ -415,32 +463,93 @@ const TimelineWrapper: React.FunctionComponent = observer( baseConfig.trackEventRenderers ); - const store = new TimelineStore(trackSpecifications); + const store1 = new TimelineStore(trackSpecifications); + const store2 = new TimelineStore(trackSpecifications); - setStore(store); + setStores([store1, store2]); - (window as any).store = store; + (window as any).store = [store1, store2]; }, []); - if (store) { + if (stores) { + const [store1, store2] = stores; return ( - 'VAF', - // renderTrack: (store: TimelineStore) => ( - // - // ), - // height: () => VAF_CHART_ROW_HEIGHT, - // labelForExport: 'VAF', - // }, - // ]} - /> + <> +
+ 'VAF', + renderTrack: (store: TimelineStore) => ( + + ), + height: (store: TimelineStore) => { + return store.vafChartHeight; + }, + labelForExport: 'VAF', + }, + ]} + /> +
+
+
+

Genomic Evolution

+ +
+ 'VAF', + renderTrack: (store: TimelineStore) => ( + + ), + height: (store: TimelineStore) => { + return store.vafChartHeight; + }, + labelForExport: 'VAF', + }, + ]} + /> +
+
+ ); } else { return
; diff --git a/src/pages/patientView/timeline2/VAFChartControls.tsx b/src/pages/patientView/timeline2/VAFChartControls.tsx new file mode 100644 index 00000000000..a880b49f487 --- /dev/null +++ b/src/pages/patientView/timeline2/VAFChartControls.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { observer } from 'mobx-react'; +import { TimelineStore } from 'cbioportal-clinical-timeline'; +import ReactSelect from 'react-select'; +import SampleManager from '../SampleManager'; +import LabeledCheckbox from '../../../shared/components/labeledCheckbox/LabeledCheckbox'; +import autobind from 'autobind-decorator'; + +interface IVAFChartControlsProps { + store: TimelineStore; + sampleManager: SampleManager; +} + +export const GROUP_BY_NONE = 'None'; + +const VAFChartControls: React.FunctionComponent< + IVAFChartControlsProps +> = observer(function({ store, sampleManager }) { + const groupByOptions = [ + { + label: GROUP_BY_NONE, + value: GROUP_BY_NONE, + }, + ...sampleManager + .getClinicalAttributeList(sampleManager.samples) + .map(item => ({ + label: `${item.value}`, + value: `${item.id}`, + })), + ]; + + function groupByValue() { + let value = groupByOptions.find( + opt => opt.value == store.groupByOption + ); + + return value + ? { + label: value.label, + value: store.groupByOption, + } + : ''; + } + + return ( +
+ Group by: +
+ { + store.setGroupByOption(option ? option.value : ''); + }} + clearable={false} + searchable={true} + /> +
+
+ + store.setOnlyShowSelectedInVAFChart( + !store.onlyShowSelectedInVAFChart + ) + } + labelProps={{ style: { marginRight: 10 } }} + inputProps={{ 'data-test': 'TableShowOnlyHighlighted' }} + > + + Show only selected mutations + + +
+
+ { + store.setVafChartLogScale(!store.vafChartLogScale); + }} + labelProps={{ style: { marginRight: 10 } }} + inputProps={{ 'data-test': 'VAFLogScale' }} + > + Log scale + +
+
+ { + store.setVafChartYAxisToDataRange( + !store.vafChartYAxisToDataRange + ); + }} + labelProps={{ style: { marginRight: 10 } }} + inputProps={{ 'data-test': 'VAFDataRange' }} + > + + Set y-axis to data range + + +
+
+ ); +}); +export default VAFChartControls; diff --git a/src/pages/patientView/timeline2/VAFChartWrapper.tsx b/src/pages/patientView/timeline2/VAFChartWrapper.tsx index 8ba5ede0ebf..f5ebd3ae91d 100644 --- a/src/pages/patientView/timeline2/VAFChartWrapper.tsx +++ b/src/pages/patientView/timeline2/VAFChartWrapper.tsx @@ -1,22 +1,79 @@ import React, { ReactSVGElement } from 'react'; -import { observer } from 'mobx-react-lite'; -import { TimelineStore } from 'cbioportal-clinical-timeline'; -import SampleMarker from 'pages/patientView/timeline2/SampleMarker'; +import { Observer, observer } from 'mobx-react'; +import { computed, observable, action } from 'mobx'; +import autobind from 'autobind-decorator'; +import { + TimelineStore, + TimelineEvent, + getPointInTrimmedSpaceFromScreenRead, +} from 'cbioportal-clinical-timeline'; +import SampleMarker from './SampleMarker'; import { ISampleMetaDeta, ITimeline2Props, } from 'pages/patientView/timeline2/TimelineWrapper'; +import { CoverageInformation } from '../../resultsView/ResultsViewPageStoreUtils'; +import { Mutation, Sample } from 'cbioportal-ts-api-client'; +import { computeRenderData, IPoint } from '../mutation/VAFLineChartUtils'; +import PatientViewMutationsDataStore from '../mutation/PatientViewMutationsDataStore'; import _ from 'lodash'; +import { Popover } from 'react-bootstrap'; +import ComplexKeyMap from '../../../shared/lib/complexKeyDataStructures/ComplexKeyMap'; +import classnames from 'classnames'; +import { Portal } from 'react-portal'; +import survivalStyles from '../../resultsView/survival/styles.module.scss'; +import styles from '../mutation/styles.module.scss'; +import { mutationTooltip } from '../mutation/PatientViewMutationsTabUtils'; +import SampleManager from '../SampleManager'; +import { stringListToIndexSet } from 'cbioportal-frontend-commons'; +import { makeUniqueColorGetter } from '../../../shared/components/plots/PlotUtils'; +import { GROUP_BY_NONE } from './VAFChartControls'; +import { MutationStatus } from '../mutation/PatientViewMutationsTabUtils'; interface IVAFChartWrapperProps { + dataStore: PatientViewMutationsDataStore; store: TimelineStore; sampleMetaData: ISampleMetaDeta; + samples: Sample[]; + mutationProfileId: string; + coverageInformation: CoverageInformation; + sampleManager: SampleManager; } -const VAFPoint: React.FunctionComponent<{ x: number; y: number }> = function({ - x, - y, -}) { +const HIGHLIGHT_LINE_STROKE_WIDTH = 6; +const HIGHLIGHT_COLOR = '#318ec4'; +const SCATTER_DATA_POINT_SIZE = 3; +const MIN_LOG_ARG = 0.001; + +const VAFPoint: React.FunctionComponent<{ + x: number; + y: number; + color: string; + tooltipDatum: { + sampleId: string; + vaf: number; + mutationStatus: MutationStatus; + } | null; + mutation: Mutation; + dataStore: PatientViewMutationsDataStore; +}> = function({ x, y, color, tooltipDatum, mutation, dataStore }) { + const onMouseOverEvent = (mouseEvent: any) => { + dataStore.setTooltipModel(tooltipDatum, mutation, mouseEvent, true); + }; + + const onMouseOutEvent = (mouseEvent: any) => { + dataStore.setTooltipModel(null, null, mouseEvent, true); + }; + + const onMouseClickEvent = (mouseEvent: any) => { + dataStore.toggleSelectedMutation(mutation); + }; + + const onMouseMoveEvent = (mouseEvent: any) => { + mouseEvent.persist(); + dataStore.setTooltipModel(tooltipDatum, mutation, mouseEvent, true); + }; + return ( = function({ role="presentation" shape-rendering="auto" style={{ - stroke: 'rgb(0, 0, 0)', + stroke: `${color}`, fill: 'white', strokeWidth: 2, opacity: 1, }} + onMouseOver={onMouseOverEvent} + onMouseOut={onMouseOutEvent} + onClick={onMouseClickEvent} + onMouseMove={onMouseMoveEvent} /> ); @@ -42,93 +103,537 @@ const VAFPointConnector: React.FunctionComponent<{ y1: number; x2: number; y2: number; -}> = function({ x1, y1, x2, y2 }) { + color: string; + tooltipDatum: { + sampleId: string; + vaf: number; + mutationStatus: MutationStatus; + } | null; + mutation: Mutation; + dataStore: PatientViewMutationsDataStore; +}> = function({ x1, y1, x2, y2, color, tooltipDatum, mutation, dataStore }) { + const onMouseOverEvent = (mouseEvent: any) => { + dataStore.setTooltipModel(tooltipDatum, mutation, mouseEvent, false); + }; + + const onMouseOutEvent = (mouseEvent: any) => { + dataStore.setTooltipModel(null, null, mouseEvent, false); + }; + + const onMouseClickEvent = (mouseEvent: any) => { + dataStore.toggleSelectedMutation(mutation); + }; + + const onMouseMoveEvent = (mouseEvent: any) => { + mouseEvent.persist(); + dataStore.setTooltipModel(tooltipDatum, mutation, mouseEvent, false); + }; + return ( - + + + ); }; -const dataHeight = 100; -const footerHeight = 20; -export const VAF_CHART_ROW_HEIGHT = _.sum([dataHeight, footerHeight]); +@observer +export default class VAFChartWrapper extends React.Component< + IVAFChartWrapperProps, + {} +> { + @computed get sampleEvents() { + return this.props.store.allItems.filter( + event => event.event!.eventType === 'SPECIMEN' + ); + } -const VAFChartWrapper: React.FunctionComponent< - IVAFChartWrapperProps -> = observer(function({ store, sampleMetaData }) { - const samples = store.allItems.filter( - event => event.event.eventType === 'SPECIMEN' - ); + @computed get headerHeight() { + return 20; + } - let lastY: number | undefined; + @computed get dataHeight() { + return 200; + } - return ( - // - - {/**/} - {samples.map((event, i) => { - const x1 = store.getPosition(event)!.pixelLeft; - let y1; - let x2, y2; - - // temporary crap to fake y position - y1 = lastY || Math.random() * dataHeight; - lastY = y1; - - const nextEvent = samples[i + 1]; - - if (nextEvent) { - x2 = store.getPosition(nextEvent)!.pixelLeft; - lastY = Math.random() * dataHeight; - y2 = lastY; + @action + recalculateTotalHeight() { + let footerHeight: number = 0; + let yPosition = this.sampleYPosition; + for (let index in yPosition) { + if (yPosition[index] > footerHeight) + footerHeight = yPosition[index]; + } + footerHeight = footerHeight + 20; + + this.props.store.setVafChartHeight( + _.sum([this.dataHeight, footerHeight]) + ); + + return _.sum([this.dataHeight, footerHeight]); + } + + @computed get mutations() { + if (this.props.store.onlyShowSelectedInVAFChart) { + return this.props.dataStore.allData.filter(m => + this.props.dataStore.isMutationSelected(m[0]) + ); + } else { + return this.props.dataStore.allData; + } + } + + @computed get renderData() { + return computeRenderData( + this.props.samples, + this.mutations, + this.props.sampleManager.sampleIdToIndexMap, + this.props.mutationProfileId, + this.props.coverageInformation, + this.props.store.groupByOption!, + this.sampleGroupByValue + ); + } + + @computed get lineData() { + let scaledData: IPoint[][] = []; + this.renderData.lineData.map((dataPoints: IPoint[], index: number) => { + scaledData[index] = []; + dataPoints.map((dataPoint: IPoint, i: number) => { + scaledData[index].push({ + x: this.xPosition[dataPoint.sampleId], + y: this.yPosition[dataPoint.y], + sampleId: dataPoint.sampleId, + mutation: dataPoint.mutation, + mutationStatus: dataPoint.mutationStatus, + }); + }); + }); + return scaledData; + } + + @computed get mutationToDataPoints() { + const map = new ComplexKeyMap(); + for (const lineData of this.lineData) { + map.set( + { + hugoGeneSymbol: lineData[0].mutation.gene.hugoGeneSymbol, + proteinChange: lineData[0].mutation.proteinChange, + }, + lineData + ); + } + return map; + } + + @computed get xPosition() { + return this.props.store.xPositionBySampleId; + } + + @computed get yPosition() { + let scaledY: { [originalY: number]: number } = {}; + let minY = this.dataHeight, + maxY = 0; + this.renderData.lineData.forEach((data: IPoint[], index: number) => { + data.forEach((d: IPoint, i: number) => { + if (this.props.store.vafChartLogScale) + scaledY[d.y] = + this.dataHeight - + (Math.log10(Math.max(MIN_LOG_ARG, d.y)) / 2 + 1) * + this.dataHeight; + else scaledY[d.y] = this.dataHeight - d.y * this.dataHeight; + if (scaledY[d.y] < minY) minY = scaledY[d.y]; + if (scaledY[d.y] > maxY) maxY = scaledY[d.y]; + }); + }); + + if ( + this.props.store.vafChartYAxisToDataRange && + (minY > 0 || maxY < this.dataHeight) + ) { + // recalculate scaledY for this range only + this.renderData.lineData.forEach( + (data: IPoint[], index: number) => { + data.forEach((d: IPoint, i: number) => { + scaledY[d.y] = + ((this.dataHeight - 1) * (scaledY[d.y] - minY)) / + (maxY - minY) + + 1; + }); + } + ); + } + return scaledY; + } + + @computed get sampleIdOrder() { + return stringListToIndexSet( + this.props.sampleManager.getSampleIdsInOrder() + ); + } + + @computed get sampleGroupByColors() { + let groupByColors: { [clinicalValue: string]: string } = {}; + const uniqueColorGetter = makeUniqueColorGetter(); + const clinicalAttributeSamplesMap = this.props.sampleManager.getClinicalAttributeSampleList( + this.props.sampleManager.samples, + this.props.store.groupByOption! + ); + clinicalAttributeSamplesMap.forEach( + (sampleList: string[], clinicalValue: any) => { + groupByColors[clinicalValue] = uniqueColorGetter(); + } + ); + return groupByColors; + } + + @computed get sampleGroupByLabels() { + let groupByLabels: string[] = []; + const uniqueColorGetter = makeUniqueColorGetter(); + const clinicalAttributeSamplesMap = this.props.sampleManager.getClinicalAttributeSampleList( + this.props.sampleManager.samples, + this.props.store.groupByOption! + ); + clinicalAttributeSamplesMap.forEach( + (sampleList: string[], clinicalValue: any) => { + groupByLabels.push(clinicalValue); + } + ); + return groupByLabels; + } + + @computed get sampleGroupByValue() { + let groupByValue: { [sampleId: string]: string } = {}; + if ( + !( + this.props.store.groupByOption == null || + this.props.store.groupByOption === GROUP_BY_NONE + ) + ) { + this.props.sampleManager.samples.forEach((sample, i) => { + groupByValue[ + sample.id + ] = SampleManager!.getClinicalAttributeInSample( + sample, + this.props.store.groupByOption! + )!.value; + }); + } + return groupByValue; + } + + @computed get sampleYPosition() { + // compute sample y position inside the footer + let yStart = 0; + let yPosition: { [sampleId: string]: number } = {}; + let xCount: number[] = []; + // if a groupByOption was selected, arrange samples by clinical attribute value + if ( + !( + this.props.store.groupByOption == null || + this.props.store.groupByOption === GROUP_BY_NONE + ) + ) { + // get for each clinical attribute value the list of corresponding samples + let clinicalAttributeSamplesMap = this.props.sampleManager.getClinicalAttributeSampleList( + this.props.sampleManager.samples, + this.props.store.groupByOption! + ); + clinicalAttributeSamplesMap.forEach( + (sampleList: string[], clinicalValue: any) => { + let lines = 0; + sampleList.forEach((sampleId, i) => { + //const sampleIndex = this.sampleIdOrder[sampleId]; + const x = this.xPosition[sampleId]; + xCount[x] = xCount[x] ? xCount[x] + 1 : 1; + if (xCount[x] > lines) lines = xCount[x]; + yPosition[sampleId] = yStart + xCount[x] * 15; + }); + // get also the y position of label + yPosition[clinicalValue] = yStart; + yStart = yStart + 15 * lines; + } + ); + } + // else arrange them only by startdate (one under the other when having same startdate) + else { + this.sampleEvents.map((event: TimelineEvent, i: number) => { + const sampleId = event.event!.attributes.find( + (att: any) => att.key === 'SAMPLE_ID' + ); + const x = this.xPosition[sampleId.value]; + xCount[x] = xCount[x] ? xCount[x] + 1 : 1; + yPosition[sampleId.value] = yStart + xCount[x] * 15; + }); + } + return yPosition; + } + + @autobind + private getHighlights() { + const highlightedMutations = []; + if (!this.props.store.onlyShowSelectedInVAFChart) { + // dont bold highlighted mutations if we're only showing highlighted mutations + highlightedMutations.push( + ...this.props.dataStore.selectedMutations + ); + } + //const mouseOverMutation = this.props.dataStore.getMouseOverMutation(); + const mouseOverMutation = this.props.dataStore.tooltipModel + ? this.props.dataStore.tooltipModel.mutation + : null; + + if (mouseOverMutation) { + highlightedMutations.push(mouseOverMutation); + } + if (highlightedMutations.length > 0) { + return highlightedMutations.map(highlightedMutation => { + const points = this.mutationToDataPoints.get({ + proteinChange: highlightedMutation.proteinChange, + hugoGeneSymbol: highlightedMutation.gene.hugoGeneSymbol, + }); + + if (!points) { + return ; } + let linePath = null; + if (points.length > 1) { + // more than one point -> we should render a path + let d = `M ${points[0].x} ${points[0].y}`; + for (let i = 1; i < points.length; i++) { + d = `${d} L ${points[i].x} ${points[i].y}`; + } + linePath = ( + + ); + } + const pointPaths = points.map(point => ( + + )); return ( - {x2 && y2 && ( - - )} - + {linePath} + {pointPaths} ); - })} + }); + } else { + return ; + } + } - - {samples.map((event, i) => { - const x = store.getPosition(event)!.pixelLeft; + private tooltipFunction(tooltipData: any) { + return mutationTooltip( + tooltipData.mutation, + tooltipData.tooltipOnPoint + ? { + mutationStatus: tooltipData.datum.mutationStatus, + sampleId: tooltipData.datum.sampleId, + vaf: tooltipData.datum.vaf, + } + : undefined + ); + } - const sampleId = event.event.attributes.find( - (att: any) => att.key === 'SAMPLE_ID' - ); - const color = - sampleMetaData.color[sampleId.value] || '#333333'; - const label = sampleMetaData.label[sampleId.value] || '-'; - - return ( - - - - ); - })} - - - ); -}); + @autobind + private getTooltipComponent() { + let mutationTooltip = this.props.dataStore.tooltipModel; + if ( + !mutationTooltip || + mutationTooltip.mouseEvent == null || + mutationTooltip.mutation == null || + mutationTooltip.datum == null + ) { + return ; + } else { + let tooltipPlacement = + mutationTooltip.mouseEvent.clientY < 250 ? 'bottom' : 'top'; + return ( + + + {this.tooltipFunction(mutationTooltip)} + + + ); + } + } + + render() { + return ( + + {this.renderData.lineData.map( + (data: IPoint[], index: number) => { + return data.map((d: IPoint, i: number) => { + let x1 = this.xPosition[d.sampleId], + x2; + let y1 = this.yPosition[d.y], + y2; + + const nextPoint: IPoint = data[i + 1]; + if (nextPoint) { + x2 = this.xPosition[nextPoint.sampleId]; + y2 = this.yPosition[nextPoint.y]; + } + + let tooltipDatum: { + mutationStatus: MutationStatus; + sampleId: string; + vaf: number; + } = { + mutationStatus: d.mutationStatus, + sampleId: d.sampleId, + vaf: d.y, + }; -export default VAFChartWrapper; + let color = 'rgb(0,0,0)'; + if ( + !( + this.props.store.groupByOption == null || + this.props.store.groupByOption === + GROUP_BY_NONE + ) + ) + color = this.sampleGroupByColors[ + this.sampleGroupByValue[d.sampleId] + ]; + + return ( + + {x2 && y2 && ( + + )} + + + ); + }); + } + )} + + + {this.sampleEvents.map( + (event: TimelineEvent, i: number) => { + const sampleId = event.event!.attributes.find( + (att: any) => att.key === 'SAMPLE_ID' + ); + const x = this.xPosition[sampleId.value]; + const y = this.sampleYPosition[sampleId.value]; + const color = + this.props.sampleMetaData.color[ + sampleId.value + ] || '#333333'; + const label = + this.props.sampleMetaData.label[ + sampleId.value + ] || '-'; + return ( + + + + ); + } + )} + + + {this.sampleGroupByLabels.map((label, index) => { + return ( + + + {label} + + + ); + })} + + {this.getHighlights} + {this.getTooltipComponent} + + ); + } +}