import * as React from 'react'; import { observer, Observer } from 'mobx-react'; import { computed, observable, action, makeObservable } from 'mobx'; import { bind } from 'bind-decorator'; import ifNotDefined from '../../lib/ifNotDefined'; import { calculateBoxPlotModel } from '../../lib/boxPlotUtils'; import { VictoryBoxPlot, VictoryChart, VictoryAxis, VictoryScatter, VictoryLegend, VictoryLabel, } from 'victory'; import { IBaseScatterPlotData } from './ScatterPlot'; import { getBottomLegendHeight, getDeterministicRandomNumber, getLegendItemsPerRow, getMaxLegendLabelWidth, LegendDataWithId, separateScatterDataByAppearance, } from './PlotUtils'; import { logicalAnd } from '../../lib/LogicUtils'; import { tickFormatNumeral } from 'cbioportal-frontend-commons'; import { makeScatterPlotSizeFunction } from './PlotUtils'; import { CBIOPORTAL_VICTORY_THEME, axisTickLabelStyles, getTextWidth, ScatterPlotTooltip, ScatterPlotTooltipHelper, truncateWithEllipsis, wrapText, } from 'cbioportal-frontend-commons'; import autobind from 'autobind-decorator'; import { dataPointIsLimited } from 'shared/components/plots/PlotUtils'; import _ from 'lodash'; import { IAxisLogScaleParams, IBoxScatterPlotPoint, } from 'pages/resultsView/plots/PlotsTabUtils'; import { Popover } from 'react-bootstrap'; import * as ReactDOM from 'react-dom'; import classnames from 'classnames'; import WindowStore from '../window/WindowStore'; import LegendDataComponent from './LegendDataComponent'; import LegendLabelComponent from './LegendLabelComponent'; export interface IBaseBoxScatterPlotPoint { value: number; jitter?: number; // between -1 and 1 } export interface IBoxScatterPlotData { label: string; median: number; data: D[]; } export interface IBoxScatterPlotProps { svgId?: string; title?: string; data: IBoxScatterPlotData[]; chartBase: number; startDataAxisAtZero?: boolean; domainPadding?: number; // see https://formidable.com/open-source/victory/docs/victory-chart/#domainpadding highlight?: (d: D) => boolean; size?: | number | ((d: D, active: boolean, isHighlighted?: boolean) => number); fill?: string | ((d: D) => string); stroke?: string | ((d: D) => string); 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 scatterPlotTooltip?: (d: D) => JSX.Element; boxPlotTooltip?: (stats: BoxModel[], labels: string[]) => JSX.Element; legendData?: LegendDataWithId[]; logScale?: IAxisLogScaleParams | undefined; // log scale along the point data axis excludeLimitValuesFromBoxPlot?: boolean; axisLabelX?: string; axisLabelY?: string; horizontal?: boolean; // whether the box plot is horizontal 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 svgRef?: (svgContainer: SVGElement | null) => void; compressXAxis?: boolean; legendTitle?: string | string[]; } type BoxModel = { min: number; max: number; median: number; q1: number; q3: number; x?: number; y?: number; }; const RIGHT_GUTTER = 130; // room for legend const LEGEND_COLUMN_PADDING = 45; 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 BOTTOM_LEGEND_PADDING = 15; const HORIZONTAL_OFFSET = 8; const VERTICAL_OFFSET = 17; const UTILITIES_MENU_HEIGHT = 20; const BOX_STYLES = { min: { stroke: '#999999' }, max: { stroke: '#999999' }, q1: { fill: '#eeeeee' }, q3: { fill: '#eeeeee' }, median: { stroke: '#999999', strokeWidth: 1 }, }; @observer export default class BoxScatterPlot< D extends IBaseBoxScatterPlotPoint > extends React.Component, {}> { @observable.ref private container: HTMLDivElement; @observable.ref private boxPlotTooltipModel: any | null; @observable private mousePosition = { x: 0, y: 0 }; private scatterPlotTooltipHelper: ScatterPlotTooltipHelper = new ScatterPlotTooltipHelper(); constructor(props: any) { super(props); makeObservable(this); } @bind private containerRef(container: HTMLDivElement) { this.container = container; } get mouseEvents() { return this.scatterPlotTooltipHelper.mouseEvents; } get scatterPlotTooltipModel() { return this.scatterPlotTooltipHelper.tooltipModel; } get pointHovered() { return this.scatterPlotTooltipHelper.pointHovered; } private get boxPlotEvents() { if (this.props.boxPlotTooltip) { const self = this; return [ { target: ['min', 'max', 'media', 'q1', 'q3'], eventHandlers: { onMouseEnter: () => { return [ { mutation: (props: any) => { self.boxPlotTooltipModel = props; }, }, ]; }, onMouseLeave: () => { return [ { mutation: () => { self.boxPlotTooltipModel = null; }, }, ]; }, }, }, ]; } return []; } private get title() { if (this.props.title) { const text = wrapText( this.props.title, this.chartWidth, axisTickLabelStyles.fontFamily, `${axisTickLabelStyles.fontSize}px` ); return ( ); } else { return null; } } @computed get chartWidth() { if (this.props.horizontal) { return this.props.chartBase; } else { return this.chartExtent; } } @computed get chartHeight() { if (this.props.horizontal) { return this.chartExtent; } else { return this.props.chartBase; } } @computed get sideLegendX() { return this.chartWidth - 20; } @computed get legendLocation() { if ( (this.props.legendLocationWidthThreshold !== undefined && // if chart meets width threshold this.chartWidth > this.props.legendLocationWidthThreshold) || (this.props.legendData && this.props.legendData.length > 7) // too many legend data ) { return 'bottom'; } else { return 'right'; } } @computed get bottomLegendHeight() { if ( !this.props.legendData || !this.props.legendData.length || this.legendLocation !== 'bottom' ) { return 0; } else { return getBottomLegendHeight( this.legendItemsPerRow, this.props.legendData, this.props.legendTitle ); } } @computed get maxLegendLabelWidth() { if (this.props.legendData) { return getMaxLegendLabelWidth(this.props.legendData); } return 0; } @computed get legendItemsPerRow() { return getLegendItemsPerRow( this.maxLegendLabelWidth, this.svgWidth, LEGEND_COLUMN_PADDING, this.props.legendTitle ); } 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, ...rest } = x; if (Array.isArray(name)) { name = (name as string[]).join(' '); // flatten labels by joining with space } return { name, ...rest, }; }); } const orientation = this.legendLocation === 'right' ? 'vertical' : 'horizontal'; return ( } labelComponent={ } events={[ { childName: 'all', target: ['data', 'labels'], eventHandlers: { onClick: () => [ { target: 'data', mutation: (props: any) => { const datum: LegendDataWithId = props.data[props.index]; if (datum.highlighting) { datum.highlighting.onClick( datum ); } }, }, ], }, }, ]} orientation={orientation} itemsPerRow={ this.legendLocation === 'right' ? undefined : this.legendItemsPerRow } rowGutter={this.legendLocation === 'right' ? undefined : -5} gutter={ this.legendLocation === 'right' ? undefined : LEGEND_COLUMN_PADDING } data={legendData} x={this.legendLocation === 'right' ? this.sideLegendX : 0} y={ this.legendLocation === 'right' ? 100 : this.svgHeight - this.bottomLegendHeight } title={this.props.legendTitle} titleOrientation={ this.legendLocation === 'right' ? 'top' : 'left' } style={{ title: { fontSize: 15, fontWeight: 'bold', }, }} titleComponent={ } /> ); } else { return null; } } @computed get plotDomain() { // data extremes let max = Number.NEGATIVE_INFINITY; let min = Number.POSITIVE_INFINITY; for (const d of this.props.data) { for (const d2 of d.data) { max = Math.max(d2.value, max); min = Math.min(d2.value, min); } } if (this.props.logScale) { min = this.props.logScale.fLogScale(min, 0); max = this.props.logScale.fLogScale(max, 0); } if (this.props.startDataAxisAtZero) { min = Math.min(0, min); } let x: number[], y: number[]; const dataDomain = [min, max]; const categoryDomain = [ this.categoryCoord(0), this.categoryCoord(Math.max(1, this.props.data.length - 1)), ]; if (this.props.horizontal) { x = dataDomain; y = categoryDomain; } else { x = categoryDomain; y = dataDomain; } return { x, y }; } @computed get categoryAxisDomainPadding() { // padding needs to be at least half a box width plus a bit return Math.max(this.boxWidth / 2 + 30, this.domainPadding); } @computed get dataAxisDomainPadding() { return this.domainPadding; } @computed get domainPadding() { if (this.props.domainPadding === undefined) { return PLOT_DATA_PADDING_PIXELS; } else { return this.props.domainPadding; } } @computed get dataAxis(): 'x' | 'y' { if (this.props.horizontal) { return 'x'; } else { return 'y'; } } @computed get categoryAxis(): 'x' | 'y' { if (this.props.horizontal) { return 'y'; } else { return 'x'; } } @computed get chartDomainPadding() { return { [this.dataAxis]: this.dataAxisDomainPadding, [this.categoryAxis]: this.categoryAxisDomainPadding, }; } @computed get chartExtent() { const miscPadding = 100; // specifying chart width in victory doesnt translate directly to the actual graph size const numBoxes = this.props.data.length; return ( this.categoryCoord(numBoxes - 1) + 2 * this.categoryAxisDomainPadding + 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() { return this.leftPadding + this.chartWidth + this.rightPadding; } @computed get svgHeight() { return this.topPadding + this.chartHeight + this.bottomPadding; } @computed get boxSeparation() { return 0.5 * this.boxWidth; } @computed get boxWidth() { return this.props.boxWidth || 10; } private jitter(d: D, randomNumber: number) { // randomNumber: between -1 and 1 return 0.2 * this.boxWidth * randomNumber; } @bind private scatterPlotX(d: IBaseScatterPlotData & D) { if (this.props.logScale && this.props.horizontal) { return this.props.logScale.fLogScale(d.x, 0); } else { let jitter = 0; if (!this.props.horizontal) { let jitterRandomNumber = d.jitter; if (jitterRandomNumber === undefined) { jitterRandomNumber = getDeterministicRandomNumber(d.y, [ -1, 1, ]); } jitter = this.jitter(d, jitterRandomNumber); } return d.x + jitter; } } @bind private scatterPlotY(d: IBaseScatterPlotData & D) { if (this.props.logScale && !this.props.horizontal) { return this.props.logScale.fLogScale(d.y, 0); } else { let jitter = 0; if (this.props.horizontal) { let jitterRandomNumber = d.jitter; if (jitterRandomNumber === undefined) { jitterRandomNumber = getDeterministicRandomNumber(d.x, [ -1, 1, ]); } jitter = this.jitter(d, jitterRandomNumber); } return d.y + jitter; } } @computed get scatterPlotSize() { const highlight = this.props.highlight; const size = this.props.size; // need to regenerate this function whenever highlight changes in order to trigger immediate Victory rerender return makeScatterPlotSizeFunction(highlight, size); } @computed get labels() { return this.props.data.map(d => { if (!!this.props.compressXAxis) { return truncateWithEllipsis( d.label, 50, axisTickLabelStyles.fontFamily, axisTickLabelStyles.fontSize + 'px' ); } return d.label; }); } @bind private formatCategoryTick(t: number, index: number) { //return lowerCaseAndCapitalizeString(this.labels[index]); return this.labels[index]; } @bind private formatNumericalTick(t: number, i: number, ticks: number[]) { return tickFormatNumeral( t, ticks, this.props.logScale && !this.props.useLogSpaceTicks ? this.props.logScale.fInvLogScale : undefined ); } @computed get horzAxis() { // several props below are undefined in horizontal mode, thats because in horizontal mode // this axis is for numbers, not categories return ( } axisLabelComponent={ } /> ); } @computed get yAxisLabel(): string[] { if (this.props.axisLabelY) { return wrapText( this.props.axisLabelY, this.chartHeight - UTILITIES_MENU_HEIGHT, axisTickLabelStyles.fontFamily, `${axisTickLabelStyles.fontSize}px` ); } return []; } @computed get yAxisLabelVertOffset(): number { if (this.props.horizontal) { return -1 * this.biggestCategoryLabelSize - 24; } else if (this.yAxisLabel.length > 1) { return -30; } return -50; } @computed get vertAxis() { return ( } /> ); } @computed get scatterPlotData() { let dataAxis: 'x' | 'y' = this.props.horizontal ? 'x' : 'y'; let categoryAxis: 'x' | 'y' = this.props.horizontal ? 'y' : 'x'; const data: (D & { x: number; y: number })[] = []; for (let i = 0; i < this.props.data.length; i++) { const categoryCoord = this.categoryCoord(i); for (const d of this.props.data[i].data) { data.push( Object.assign({}, d, { [dataAxis]: d.value, [categoryAxis]: categoryCoord, } as { x: number; y: number }) ); } } return separateScatterDataByAppearance( data, ifNotDefined(this.props.fill, '0x000000'), ifNotDefined(this.props.stroke, '0x000000'), ifNotDefined(this.props.strokeWidth, 0), ifNotDefined(this.props.strokeOpacity, 1), ifNotDefined(this.props.fillOpacity, 1), ifNotDefined(this.props.symbol, 'circle'), this.props.zIndexSortBy ); } @computed get leftPadding() { // more left padding if horizontal, to make room for labels if (this.props.horizontal) { return this.biggestCategoryLabelSize; } else { return DEFAULT_LEFT_PADDING; } } @computed get topPadding() { return 0; } @computed get rightPadding() { if ( this.props.legendData && this.props.legendData.length > 0 && this.legendLocation === 'right' ) { // make room for legend return Math.max(RIGHT_GUTTER, this.maxLegendLabelWidth + 50); // + 50 makes room for circle and padding } else { return RIGHT_GUTTER; } } @computed get bottomPadding() { let paddingForLabels = DEFAULT_BOTTOM_PADDING; let paddingForLegend = 0; if (!this.props.horizontal) { // more padding if vertical, because category labels extend to bottom // more padding if axis is not compressed paddingForLabels = !!this.props.compressXAxis ? 20 : 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 biggestCategoryLabelSize() { const maxSize = Math.max( ...this.labels.map(x => getTextWidth( x, axisTickLabelStyles.fontFamily, axisTickLabelStyles.fontSize + 'px' ) ) ); if (!!this.props.compressXAxis || this.props.horizontal) { // if horizontal mode or if axis is compressed( i.e, tick labels are horizontal), 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)) ); } } private categoryCoord(index: number) { return index * (this.boxWidth + this.boxSeparation); // half box + separation + half box } @computed get categoryTickValues() { return this.props.data.map((x, i) => this.categoryCoord(i)); } @computed get boxPlotData(): BoxModel[] { const calcBoxSizes = (box: any, i: number) => { if (this.props.horizontal) { box.y = this.categoryCoord(i); } else { box.x = this.categoryCoord(i); } }; return toBoxPlotData( this.props.data, this.props.boxCalculationFilter, this.props.excludeLimitValuesFromBoxPlot, this.props.logScale, calcBoxSizes ); } @action.bound private onMouseMove(e: React.MouseEvent) { if (this.boxPlotTooltipModel !== null) { this.mousePosition.x = e.pageX; this.mousePosition.y = e.pageY; } } @autobind private getChart() { return (
{this.title} {this.legend} {this.horzAxis} {this.vertAxis} {this.scatterPlotData.map(dataWithAppearance => ( ))}
); } @autobind private getScatterPlotTooltip() { if ( this.container && this.scatterPlotTooltipModel && this.props.scatterPlotTooltip ) { return ( ); } else { return ; } } @autobind private getBoxPlotTooltipComponent() { if ( this.container && this.boxPlotTooltipModel && this.props.boxPlotTooltip ) { const maxWidth = 400; let tooltipPlacement = this.mousePosition.x > WindowStore.size.width - maxWidth ? 'left' : 'right'; return (ReactDOM as any).createPortal(
{this.props.boxPlotTooltip( [this.boxPlotTooltipModel.datum], [ this.props.data[ this.boxPlotTooltipModel.datum.eventKey ].label, ] )}
, document.body ); } else { return null; } } render() { if (!this.props.data.length) { return
No data to plot.
; } return (
{this.getChart} {this.getScatterPlotTooltip} {this.getBoxPlotTooltipComponent}
); } } export function toBoxPlotData( data: IBoxScatterPlotData[], boxCalculationFilter?: (d: D) => boolean, excludeLimitValuesFromBoxPlot?: any, logScale?: any, calcBoxSizes?: (box: BoxModel, i: number) => void ) { // when limit values are shown in the legend, exclude // these points from influencing the shape of the box plots let boxData = _.clone(data); if (excludeLimitValuesFromBoxPlot) { _.each(boxData, (o: IBoxScatterPlotData) => { o.data = _.filter( o.data, (p: IBoxScatterPlotPoint) => !dataPointIsLimited(p) ); }); } return boxData .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 (logScale) { data.push(logScale.fLogScale(next.value, 0)); } else { data.push(next.value); } } 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 const box: BoxModel = { min: model.whiskerLower, max: model.whiskerUpper, median: model.median, q1: model.q1, q3: model.q3, }; calcBoxSizes && calcBoxSizes(box, i); return box; }) .filter(box => { // filter out not well-defined boxes return logicalAnd( ['min', 'max', 'median', 'q1', 'q3'].map(key => { return !isNaN((box as any)[key]); }) ); }); }