import * as React from 'react'; import { Observer, observer } from 'mobx-react'; import { computed, makeObservable, observable } from 'mobx'; import { bind } from 'bind-decorator'; import { axisTickLabelStyles, baseLabelStyles, CBIOPORTAL_VICTORY_THEME, getTextWidth, stringListToIndexSet, } from 'cbioportal-frontend-commons'; import autobind from 'autobind-decorator'; import _ from 'lodash'; import { VictoryAxis, VictoryBar, VictoryChart, VictoryLabel, VictoryStack, VictoryLegend, VictoryGroup, } from 'victory'; import { tickFormatNumeral } from 'cbioportal-frontend-commons'; import { makeUniqueColorGetter } from '../../shared/components/plots/PlotUtils'; import { makePlotData, makeBarSpecs, sortDataByCategory, } from '../../shared/components/plots/MultipleCategoryBarPlotUtils'; import * as ReactDOM from 'react-dom'; import { Popover } from 'react-bootstrap'; import classnames from 'classnames'; import { toConditionalPrecisionWithMinimum } from 'shared/lib/FormatUtils'; import { IStringAxisData } from 'pages/resultsView/plots/PlotsTabUtils'; import WindowStore from 'shared/components/window/WindowStore'; export interface IMultipleCategoryBarPlotProps { svgId?: string; domainPadding?: number; horzData?: IStringAxisData['data']; vertData?: IStringAxisData['data']; plotData?: IMultipleCategoryBarPlotData[]; categoryToColor?: { [cat: string]: string }; barWidth: number; chartBase: number; horizontalBars?: boolean; horzCategoryOrder?: string[]; vertCategoryOrder?: string[]; axisLabelX?: string; axisLabelY?: string; legendLocationWidthThreshold?: number; percentage?: boolean; stacked?: boolean; ticksCount?: number; axisStyle?: any; countAxisLabel?: string; tooltip?: (datum: any) => JSX.Element; svgRef?: (svgContainer: SVGElement | null) => void; pValue: number | null; qValue: number | null; } export interface IMultipleCategoryBarPlotData { minorCategory: string; counts: { majorCategory: string; count: number; percentage: number }[]; } const RIGHT_GUTTER = 120; // room for legend const NUM_AXIS_TICKS = 8; const PLOT_DATA_PADDING_PIXELS = 100; const CATEGORY_LABEL_HORZ_ANGLE = 50; const DEFAULT_LEFT_PADDING = 25; const DEFAULT_BOTTOM_PADDING = 10; const LEGEND_ITEMS_PER_ROW = 4; const BOTTOM_LEGEND_PADDING = 15; const RIGHT_PADDING_FOR_LONG_LABELS = 50; @observer export default class MultipleCategoryBarPlot extends React.Component< IMultipleCategoryBarPlotProps, {} > { constructor(props: any) { super(props); makeObservable(this); } static defaultProps: Partial = { countAxisLabel: '# samples', }; @observable.ref tooltipModel: any | null = null; private mouseEvents: any = this.makeMouseEvents(); private legendClassName: string = `stacked-bar-plot-legend-${Math.random()}`; @observable computedLegendWidth = 0; @observable mousePosition = { x: 0, y: 0 }; @observable.ref private container: HTMLDivElement; @bind private containerRef(container: HTMLDivElement) { this.container = container; this.updateLegendWidth(); } @computed get getColor() { const uniqueColorGetter = makeUniqueColorGetter( _.values(this.props.categoryToColor) ); const categoryToColor: { [category: string]: string } = {}; _.forEach(this.props.categoryToColor, (color, category) => { categoryToColor[category.toLowerCase()] = color; }); return function(category: string) { category = category.toLowerCase(); if (!(category in categoryToColor)) { categoryToColor[category] = uniqueColorGetter(); } return categoryToColor[category]; }; } private makeMouseEvents() { return [ { target: 'data', eventHandlers: { onMouseOver: () => { return [ { target: 'data', mutation: (props: any) => { this.tooltipModel = props; return null; }, }, ]; }, onMouseOut: () => { return [ { target: 'data', mutation: () => { this.tooltipModel = null; return null; }, }, ]; }, }, }, ]; } @computed get chartWidth() { let specifiedWidth: number; if (this.props.horizontalBars) { specifiedWidth = this.props.chartBase; } else { specifiedWidth = this.chartExtent; } return Math.max( specifiedWidth, getTextWidth( this.props.axisLabelX || '', baseLabelStyles.fontFamily, baseLabelStyles.fontSize + 'px' ) ); // make sure theres enough room for the x-axis label } @computed get chartHeight() { let specifiedWidth: number; if (this.props.horizontalBars) { specifiedWidth = this.chartExtent; } else { specifiedWidth = this.props.chartBase; } return Math.max( specifiedWidth, getTextWidth( this.props.axisLabelY || '', baseLabelStyles.fontFamily, baseLabelStyles.fontSize + 'px' ) ); // make sure theres enough room for the y-axis label } @computed get sideLegendX() { return this.chartWidth - 20; } @computed get legendLocation() { if ( (this.props.legendLocationWidthThreshold !== undefined && this.chartWidth > this.props.legendLocationWidthThreshold) || // move to bottom if chart width is too large, leaving no room for legend on the side this.legendData.length > 15 // move to bottom if legend is too long, making it run off the screen ) { return 'bottom'; } else { return 'right'; } } @computed get bottomLegendHeight() { //height of legend in case its on bottom if (this.legendData.length === 0) { return 0; } else { const numRows = Math.ceil( this.legendData.length / LEGEND_ITEMS_PER_ROW ); return 23.7 * numRows; } } @computed get legendData() { return sortDataByCategory( this.data, d => d.minorCategory, this.minorCategoryOrder ).map(obj => ({ name: obj.minorCategory, symbol: { type: 'square', fill: this.getColor(obj.minorCategory), strokeOpacity: 0, size: 5, }, })); } private get legend() { if (this.legendData.length > 0) { return ( } /> ); } else { return null; } } @computed get data(): IMultipleCategoryBarPlotData[] { let data: IMultipleCategoryBarPlotData[] = []; if (this.props.horzData && this.props.vertData) { data = makePlotData( this.props.horzData, this.props.vertData, !!this.props.horizontalBars ); } else if (this.props.plotData) { data = this.props.plotData; } return data; } @computed get maxMajorCount() { if (this.props.percentage) { return 100; } const majorCategoryCounts: { [major: string]: number } = {}; for (const d of this.data) { for (const c of d.counts) { majorCategoryCounts[c.majorCategory] = majorCategoryCounts[c.majorCategory] || 0; if (this.props.stacked) { majorCategoryCounts[c.majorCategory] += c.count; } else { majorCategoryCounts[c.majorCategory] = Math.max( c.count, majorCategoryCounts[c.majorCategory] ); } } } return _.chain(majorCategoryCounts) .values() .max() .value() as number; } @computed get plotDomain() { // data domain is 0 to max num samples let x: number[], y: number[]; const countDomain: number[] = [0, this.maxMajorCount]; let categoryDomain: number[]; if (this.data.length > 0) { categoryDomain = [ this.categoryCoord(0), this.categoryCoord(Math.max(1, this.data[0].counts.length - 1)), ]; } else { categoryDomain = [0, 0]; } if (this.props.horizontalBars) { x = countDomain; y = categoryDomain; } else { x = categoryDomain; y = countDomain; } return { x, y }; } @computed get offset() { return this.barWidth; } @computed get categoryAxisDomainPadding() { return this.domainPadding; } @computed get countAxisDomainPadding() { return this.domainPadding; } @computed get domainPadding() { if (this.props.domainPadding === undefined) { return PLOT_DATA_PADDING_PIXELS; } else { return this.props.domainPadding; } } @computed get countAxis(): 'x' | 'y' { if (this.props.horizontalBars) { return 'x'; } else { return 'y'; } } @computed get categoryAxis(): 'x' | 'y' { if (this.props.horizontalBars) { return 'y'; } else { return 'x'; } } @computed get chartDomainPadding() { return { [this.countAxis]: this.props.percentage ? 0 : this.countAxisDomainPadding, [this.categoryAxis]: this.categoryAxisDomainPadding + this.additionalPadding, }; } @computed get additionalPadding() { //if not stacked add padding to move the bars right //and add minorCategories count to fix padding issue when there are lot of categories return this.props.stacked ? 0 : this.categoryCoord(this.data.length / 2) + this.data.length; } @computed get chartExtent() { let miscPadding = 100; // specifying chart width in victory doesnt translate directly to the actual graph size if (this.data.length > 0) { let numBars = 0; if (this.props.stacked) { numBars = this.data[0].counts.length; } else { //majorCategories*minorCategories numBars = this.data[0].counts.length * this.data.length; //add space between majorCategories miscPadding += this.categoryCoord(this.data[0].counts.length); } return ( this.categoryCoord(numBars - 1) + 2 * this.categoryAxisDomainPadding + miscPadding ); } else { return miscPadding; } } @computed get svgWidth() { return this.leftPadding + this.chartWidth + this.rightPadding; } @computed get svgHeight() { return this.topPadding + this.chartHeight + this.bottomPadding; } @computed get barSeparation() { return this.props.stacked ? 0.2 * this.barWidth : 0; } @computed get barWidth() { let barWidth = this.props.barWidth || 10; //for grouped bar chart, if number of minorCategories greater than 10 then reduce the width by half if (this.props.stacked || this.data.length <= 10) { return barWidth; } else { return barWidth / 2; } } @computed get labels() { if (this.data.length > 0) { return sortDataByCategory( this.data[0].counts.map(c => c.majorCategory), x => x, this.majorCategoryOrder ); } else { return []; } } @bind private formatCategoryTick(t: number, index: number) { //return wrapTick(this.labels[index], MAXIMUM_CATEGORY_LABEL_SIZE); return this.labels[index]; } @bind private formatNumericalTick(t: number, i: number, ticks: number[]) { return tickFormatNumeral(t, ticks); } @computed get numberOfTicks() { return this.props.ticksCount !== undefined ? this.props.ticksCount : NUM_AXIS_TICKS; } @computed get zeroCountOffset() { let addOffset = false; for (const d of this.data) { for (const c of d.counts) { if (c.count === 0) { addOffset = true; break; } } if (addOffset) break; } //add a small offset when its not a stacked bar to show empty bar if (this.props.stacked) { return 0; } else { return addOffset ? 0.01 * (this.maxMajorCount / this.numberOfTicks) : 0; } } @computed get axisStyle() { return this.props.stacked ? this.props.axisStyle || {} : { axis: { stroke: '#b3b3b3' } }; } @computed get categoryAxisStyle() { let style = this.axisStyle; if (!this.props.stacked) { let width = this.categoryCoord(this.data.length) - this.data.length * this.barSeparation; style = { ...this.axisStyle, ...{ axis: { strokeWidth: 0 } }, ...{ ticks: { stroke: 'black', size: 1, strokeLinecap: 'butt', strokeLinejoin: 'butt', strokeWidth: width, }, }, }; } return style; } @computed get horzAxis() { // several props below are undefined in horizontal mode, thats because in horizontal mode // this axis is for numbers, not categories const label = [this.props.axisLabelX]; if (this.props.horizontalBars) { label.unshift( `${this.props.countAxisLabel}${ this.props.percentage ? ' (%)' : '' }` ); } const style = this.props.horizontalBars ? this.axisStyle : this.categoryAxisStyle; return ( } axisLabelComponent={ } style={style} /> ); } @computed get vertAxis() { const label: string[] = []; if (this.props.axisLabelY) { label.push(this.props.axisLabelY); } if (!this.props.horizontalBars) { label.push( `${this.props.countAxisLabel}${ this.props.percentage ? ' (%)' : '' }` ); } const style = !this.props.horizontalBars ? this.axisStyle : this.categoryAxisStyle; return ( } style={style} /> ); } @computed get leftPadding() { // more left padding if horizontal, to make room for labels if (this.props.horizontalBars) { return this.biggestCategoryLabelSize; } else { return DEFAULT_LEFT_PADDING; } } @computed get topPadding() { return 0; } @computed get rightPadding() { if (this.legendData.length > 0 && this.legendLocation === 'right') { // make room for legend return this.biggestLegendLabelWidth + 20; } else { // make room for legend at bottom return Math.max( RIGHT_PADDING_FOR_LONG_LABELS, this.computedLegendWidth - this.chartWidth ); } } @computed get bottomPadding() { let paddingForLabels = DEFAULT_BOTTOM_PADDING; let paddingForLegend = 0; if (!this.props.horizontalBars) { // more padding if vertical, because category labels extend to bottom paddingForLabels = this.biggestCategoryLabelSize; } if (this.legendLocation === 'bottom') { // more padding if legend location is "bottom", to make room for legend paddingForLegend = this.bottomLegendHeight + BOTTOM_LEGEND_PADDING; } return paddingForLabels + paddingForLegend; } @computed get biggestLegendLabelWidth() { return Math.max( ...this.legendData.map(x => getTextWidth( x.name, baseLabelStyles.fontFamily, baseLabelStyles.fontSize + 'px' ) ) ); } @computed get biggestCategoryLabelSize() { const maxSize = Math.max( ...this.labels.map(x => getTextWidth( x, axisTickLabelStyles.fontFamily, axisTickLabelStyles.fontSize + 'px' ) ) ); if (this.props.horizontalBars) { // 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)) ); } } @computed get minorCategoryOrder() { let order; if (this.props.horizontalBars) { order = this.props.horzCategoryOrder; } else { order = this.props.vertCategoryOrder; } if (order) { return stringListToIndexSet(order); } else { return undefined; } } @computed get majorCategoryOrder() { let order; if (this.props.horizontalBars) { order = this.props.vertCategoryOrder; } else { order = this.props.horzCategoryOrder; } if (order) { return stringListToIndexSet(order); } else { return undefined; } } @autobind private categoryCoord(index: number) { return index * (this.barWidth + this.barSeparation); // half box + separation + half box } @computed get categoryTickValues() { if (this.data.length > 0) { return this.data[0].counts.map((x, i) => this.categoryCoord(i)); } else { return []; } } private get bars() { const barSpecs = makeBarSpecs( this.data, this.minorCategoryOrder, this.majorCategoryOrder, this.getColor, this.categoryCoord, !!this.props.horizontalBars, !!this.props.stacked, !!this.props.percentage ); return barSpecs.map(spec => ( ({ ...datum, y: datum.y + this.zeroCountOffset, }))} events={this.mouseEvents} /> )); } private tooltipFunction(datum: any) { if (this.props.tooltip) { return this.props.tooltip(datum); } return (
{datum.majorCategory}
{datum.minorCategory}: {datum.count} sample {datum.count === 1 ? '' : 's'} ({datum.percentage}%)
); } @computed get tooltipComponent() { if (!this.tooltipModel) { return null; } else { const maxWidth = 400; let tooltipPlacement = ''; let dx = 0; let dy = 0; let transform = ''; if (this.props.horizontalBars) { tooltipPlacement = 'bottom'; dy = 10; transform = 'translate(-50%,0%)'; } else { dy = -17; if (this.mousePosition.x > WindowStore.size.width - maxWidth) { tooltipPlacement = 'left'; dx = -8; transform = 'translate(-100%,0%)'; } else { tooltipPlacement = 'right'; dx = 8; } } return (ReactDOM as any).createPortal( {this.tooltipFunction( this.tooltipModel.datum || this.tooltipModel.data[0] )} , document.body ); } } @computed get chartEtl() { if (this.props.stacked) { return ( {this.bars} ); } return ( {this.bars} ); } @autobind private getChart() { if (this.data.length > 0) { return (
{ if (this.props.svgRef) { this.props.svgRef(ref); } }} > {this.legend} {this.horzAxis} {this.vertAxis} {this.chartEtl}
); } else { return No data to plot.; } } @autobind private onMouseMove(e: React.MouseEvent) { this.mousePosition.x = e.pageX; this.mousePosition.y = e.pageY; } private updateLegendWidth() { if (this.container) { const legend = this.container .getElementsByClassName(this.legendClassName) .item(0); if (legend) { this.computedLegendWidth = legend.getBoundingClientRect().width; } } } componentDidUpdate() { this.updateLegendWidth(); } render() { if (!this.data.length) { return
No data to plot.
; } return (
{this.getChart} {this.tooltipComponent}
); } } type PQValueLabelProps = { x: number; y: number; pValue: number | null; qValue: number | null; }; const PQValueLabel: React.FunctionComponent = props => { const pFormatted = formatLabel('p', props.pValue); const qFormatted = formatLabel('q', props.qValue); return (
{pFormatted}
{qFormatted}
); function formatLabel(name: string, value: number | null) { if (value != null) { return ( <> {name}{' '} {toConditionalPrecisionWithMinimum( value, 3, 0.01, -10, true )} ); } return ''; } };