import * as React from 'react'; import _ from 'lodash'; import { VictoryChart, VictoryLegend, VictoryScatter, VictoryAxis, VictoryLabel, VictoryStack, VictoryBar, } from 'victory'; import { tsvFormatRows } from 'd3-dsv'; import { IAlterationCountMap, IAlterationData, ICancerSummaryChartData, } from './CancerSummaryContent'; import { observable, computed, action, makeObservable } from 'mobx'; import { observer, Observer } from 'mobx-react'; import { CSSProperties } from 'react'; import autobind from 'autobind-decorator'; import { adjustedLongestLabelLength } from '../../../shared/lib/VictoryChartUtils'; import classnames from 'classnames'; import * as ReactDOM from 'react-dom'; import WindowStore from 'shared/components/window/WindowStore'; import { Popover } from 'react-bootstrap'; import { CBIOPORTAL_VICTORY_THEME, DownloadControls, DataType, pluralize, } from 'cbioportal-frontend-commons'; import { HORIZONTAL_OFFSET, VERTICAL_OFFSET, } from 'pages/studyView/charts/barChart/BarChartToolTip'; import { sleepUntil } from 'shared/lib/TimeUtils'; import { If, Then, Else } from 'react-if'; import { PagePath } from 'shared/enums/PagePaths'; import URL from 'url'; import { CANCER_SUMMARY_ALL_GENES } from './CancerSummaryContainer'; import { ResultsViewURLQueryEnum } from 'pages/resultsView/ResultsViewURLWrapper'; import ScrollWrapper from './ScrollWrapper'; interface CancerSummaryChartProps { colors: Record; alterationTypes: Record; data: { x: string; y: number; alterationType: string; alterationCount: number; }[][]; alterationTypeDataCounts: { x: string; y: string; profiledCount: number; notProfiledCount: number; }[]; countsByGroup: { [groupName: string]: IAlterationData }; xLabels: string[]; xAxisString: string; representedAlterations: { [alterationType: string]: boolean }; isPercentage: boolean; showLinks: boolean; hideGenomicAlterations?: boolean; gene: string; handleStudyLinkout?: (studyId: string, hugoGeneSymbol?: string) => void; } export function percentageRounder(num: number) { return _.round(num * 100, 2); } //TODO: refactor to use generic tooltip model interface ITooltipModel { x: number; y: number; alterationData: IAlterationData; groupName: string; studyId: string; } export const HORIZONTAL_SCROLLING_THRESHOLD = 37; const PLOT_DATA_PADDING_PIXELS = 20; export function mergeAlterationDataAcrossAlterationTypes( alterationData: ICancerSummaryChartData['data'] ) { // first get the group types const groupTypes = alterationData[0].map(item => item.x); // now we want to sum up the alteration rate/count across alteration types for this group const merged = alterationData.reduce((memo, alterationTypeGroups) => { alterationTypeGroups.forEach(item => { memo[item.x] = memo[item.x] || 0; memo[item.x] += item.y; }); return memo; }, {} as { [groupKey: string]: number }); // we want an array of one return groupTypes.map(groupType => { return { x: groupType, y: merged[groupType], alterationType: 'whatever', }; }); } @observer export class CancerSummaryChart extends React.Component< CancerSummaryChartProps, {} > { @observable.ref private barPlotTooltipModel: ITooltipModel | null; @observable.ref private scatterPlotTooltipModel: any | null; @observable private barToolTipCounter = 0; @observable private isBarPlotTooltipHovered = false; @observable private shouldUpdatePosition = false; // Prevents chasing tooltip @observable mousePosition = { x: 0, y: 0 }; private plotSvg: SVGElement | null = null; private dummyScrollPane: HTMLDivElement; private scrollPane: HTMLDivElement; private scrollingDummyPane = false; @observable plotElementWidth = 0; @autobind private getSvg() { return this.plotSvg; } @autobind private assignScrollPaneRef(el: HTMLDivElement) { this.scrollPane = el; if (el) { this.synchronizeScrollPanes(); $(el).scroll(this.synchronizeScrollPanes); } } @autobind private assignDummyScrollPaneRef(el: HTMLDivElement) { this.dummyScrollPane = el; if (el) { this.synchronizeScrollPanes(); $(el).scroll(this.synchronizeScrollPanes); $(el).on('mousedown', () => { this.scrollingDummyPane = true; }); $(el).on('mouseup', () => { this.scrollingDummyPane = false; }); } } @autobind private assignPlotSvgRef(el: SVGElement | null) { this.plotSvg = el; if (el) { this.plotElementWidth = el.scrollWidth; } else { this.plotElementWidth = 0; } } @autobind private synchronizeScrollPanes() { if (!this.scrollPane || !this.dummyScrollPane) { // Can't do anything if both panes don't exist yet return; } if (this.scrollingDummyPane) { // prevent infinite loop by only updating in one direction // based on whether user is clicking in the dummy pane this.scrollPane.scrollLeft = this.dummyScrollPane.scrollLeft; } else { this.dummyScrollPane.scrollLeft = this.scrollPane.scrollLeft; } } constructor(props: CancerSummaryChartProps) { super(props); makeObservable(this); } @action.bound private tooltipMouseEnter(): void { this.isBarPlotTooltipHovered = true; } @action.bound private tooltipMouseLeave(): void { this.isBarPlotTooltipHovered = false; this.barPlotTooltipModel = null; } @computed get scatterPlotTooltipComponent() { if (!this.scatterPlotTooltipModel) { return null; } else { const profiledCount = this.scatterPlotTooltipModel.datum .profiledCount as number; let tooltipMessage = 'Not profiled'; if (profiledCount > 0) { tooltipMessage = `${profiledCount} ${pluralize( 'sample', profiledCount )} profiled`; } const maxWidth = 400; let tooltipPlacement = this.mousePosition.x > WindowStore.size.width - maxWidth ? 'left' : 'right'; return (ReactDOM as any).createPortal(
{tooltipMessage}
, document.body ); } } @computed get barPlotTooltipComponent() { if (!this.barPlotTooltipModel) { return null; } else { const tooltipModel = this.barPlotTooltipModel; const maxWidth = 400; let tooltipPlacement = this.mousePosition.x > WindowStore.size.width - maxWidth ? 'left' : 'right'; return (ReactDOM as any).createPortal(
{tooltipModel.groupName}   Summary for {tooltipModel.groupName}

Gene altered in{' '} {percentageRounder( tooltipModel.alterationData.alteredSampleCount / tooltipModel.alterationData .profiledSampleTotal )} % of{' '} {tooltipModel.alterationData.profiledSampleTotal}{' '} cases

{_.reduce( this.props.alterationTypes, (memo, name: string, key: string) => { if ( key in tooltipModel!.alterationData .alterationTypeCounts && (tooltipModel!.alterationData .alterationTypeCounts as any)[ key ] > 0 ) { const alterationCount = (tooltipModel! .alterationData .alterationTypeCounts as any)[ key ]; memo.push( ); } return memo; }, [] as JSX.Element[] ).reverse()}
Alteration Frequency
{name} {percentageRounder( (tooltipModel! .alterationData .alterationTypeCounts as any)[ key ] / tooltipModel! .alterationData .profiledSampleTotal )} % ({alterationCount}{' '} {pluralize( 'case', alterationCount )} )
{ this.queryStudy(tooltipModel.studyId); }} > Query this study {this.props.gene == CANCER_SUMMARY_ALL_GENES ? '' : ` for ${this.props.gene}`}
, document.body ); } } /** * Open a new tab with the same url, then change that url via update route * so that it is just for the one study. */ private async queryStudy(studyId: string) { let gene; if (this.props.gene != CANCER_SUMMARY_ALL_GENES) { gene = this.props.gene; } this.props.handleStudyLinkout && this.props.handleStudyLinkout(studyId, gene); } private formatStudyLink(studyId: string): string { return URL.format({ pathname: '/' + PagePath.Study, query: { id: studyId }, }); } @action.bound private onMouseMove(e: React.MouseEvent) { if (this.shouldUpdatePosition) { this.mousePosition.x = e.pageX; this.mousePosition.y = e.pageY; } } private get svgWidth() { return this.chartWidth + this.rightPadding + this.leftPadding; } private get chartWidth() { return ( this.props.xLabels.length * (this.barWidth() + this.barSeparation()) + 100 ); } private get svgHeight() { return ( this.barChartHeight() + this.scatterChartHeight + this.bottomPadding - 100 ); } private get rightPadding() { return Math.max(200, this.legendWidth - this.chartWidth + 20); } private get legendWidth() { const legendItems = this.legendData.map(item => item.name); return legendItems.join('').length * 6 + legendItems.length * 40; } private get leftPadding() { return Math.max( 50, Math.max(...this.scatterData.map(datum => datum.y.length)) * 4 ); } private get bottomPadding() { return adjustedLongestLabelLength(this.props.xLabels) * 5 + 40; } private barWidth() { return 20; } private barSeparation() { return 8; } private get colorArray(): string[] { return _.map(this.props.alterationTypes, (val: string, key: string) => { return (this.props.colors as any)[key]; }); } get legendData() { const legendData = _.reduce( this.props.alterationTypes, (memo, alterationName, alterationType) => { if (alterationType in this.props.representedAlterations) { memo.push({ name: alterationName, symbol: { fill: (this.props.colors as any)[alterationType], }, }); } return memo; }, [] as { name: string; symbol: { fill: string } }[] ); return legendData.reverse(); } private get yAxisLabel() { return this.props.isPercentage ? 'Alteration Frequency' : 'Absolute Counts'; } @autobind private tickFormat() { return this.props.isPercentage ? (tick: string) => `${tick}%` : (tick: string) => tick; } /* * returns events configuration for Victory chart */ private get barPlotEvents() { const self = this; return [ { target: 'data', eventHandlers: { onMouseEnter: () => { return [ { target: 'data', mutation: (props: any) => { self.shouldUpdatePosition = true; if ( props.datum.xKey in self.props.countsByGroup ) { self.barPlotTooltipModel = { ...props, groupName: props.datum.x, alterationData: self.props.countsByGroup[ props.datum.xKey ], studyId: props.datum.xKey, }; self.barToolTipCounter++; } else { self.barPlotTooltipModel = null; } }, }, ]; }, onMouseLeave: () => { return [ { target: 'data', mutation: () => { // Freeze tool tip position and give user a moment to mouse over it self.shouldUpdatePosition = false; setTimeout(() => { // If they don't, get rid of it if ( !self.isBarPlotTooltipHovered && self.barToolTipCounter === 1 ) { self.barPlotTooltipModel = null; } self.barToolTipCounter--; }, 100); }, }, ]; }, }, }, ]; } private get scatterPlotMouseEvents() { const self = this; return [ { target: 'data', eventHandlers: { onMouseOver: () => { return [ { target: 'data', mutation: (props: any) => { if (props.datum) { self.scatterPlotTooltipModel = props; } }, }, ]; }, onMouseOut: () => { return [ { target: 'data', mutation: () => { self.scatterPlotTooltipModel = null; }, }, ]; }, onMouseEnter: () => { return [ { target: 'data', mutation: () => { self.shouldUpdatePosition = true; }, }, ]; }, onMouseLeave: () => { return [ { target: 'data', mutation: () => { self.shouldUpdatePosition = false; }, }, ]; }, }, }, ]; } /* * if we have more than threshold of bars (groups) we need to do horizontal scrolling */ get overflowStyle(): CSSProperties { return { position: 'relative', display: 'inline-block', width: this.props.xLabels.length > HORIZONTAL_SCROLLING_THRESHOLD ? '100%' : 'auto', }; } private domainPadding() { return PLOT_DATA_PADDING_PIXELS; } private categoryAxisDomainPadding() { return this.domainPadding(); } private countAxisDomainPadding() { return this.domainPadding(); } private chartDomainPadding() { return { y: this.countAxisDomainPadding(), x: this.categoryAxisDomainPadding(), }; } private barChartHeight() { return 400; } @computed get scatterChartHeight() { let miscPadding = 100; // specifying chart width in victory doesnt translate directly to the actual graph size const profiledDataTypesLength = _.uniqBy( this.scatterData, datum => datum.y ).length; if (profiledDataTypesLength > 0) { return ( profiledDataTypesLength * (this.barWidth() + this.barSeparation()) + miscPadding ); } else { return miscPadding; } } @computed get scatterPlotTopPadding() { // subtract 100 to plot scatter plot close to bar plot return this.barChartHeight() - 100; } @computed get scatterData() { return _.map(this.props.alterationTypeDataCounts, datum => { return { ...datum, symbol: datum.profiledCount > 0 ? 'plus' : 'minus', }; }); } @computed get barPlots() { // if we're not showing result broken down the alterations then we need to merge // data across alterations (and hide legend down below) const alterationData = this.props.hideGenomicAlterations ? [mergeAlterationDataAcrossAlterationTypes(this.props.data)] : this.props.data; return alterationData.map((data: any, i: number) => { return ( ); }); } @autobind private getData(dataType: DataType) { let flatdata = this.convertDataToDownloadData(this.props.data); return tsvFormatRows( [ [ this.props.xAxisString, this.yAxisLabel, 'Alteration Type', 'Alteration Count', ], ].concat(flatdata) ); } @autobind private convertDataToDownloadData( data: { x: string; y: number; alterationType: string; alterationCount: number; }[][] ): string[][] { let downloadDataArray = []; let rowcount = data.length; let colcount = data[0].length; for (var j = 0; j < colcount; j++) { for (var i = 0; i < rowcount; i++) { if (data[i][j].y != 0) { downloadDataArray.push([ data[i][j].x, data[i][j].y.toString(), data[i][j].alterationType, data[i][j].alterationCount.toString(), ]); } } } return downloadDataArray; } @autobind private getChart() { return (
} label={this.yAxisLabel} tickFormat={this.tickFormat} offsetX={50} orientation="left" /> {this.barPlots} {// we're not showing alterations, so we don't need legend !this.props.hideGenomicAlterations && ( )} } /> d.cursor, }, }} data={this.scatterData} events={this.scatterPlotMouseEvents} />
); } render() { return (
{this.getChart} {this.scatterPlotTooltipComponent} {this.barPlotTooltipComponent}
); } }