import * as React from 'react'; import _ from 'lodash'; import { observer } from 'mobx-react'; import FixedHeaderTable from 'pages/studyView/table/FixedHeaderTable'; import { action, computed, makeObservable, observable } from 'mobx'; import StudyViewViolinPlot from 'pages/studyView/charts/violinPlotTable/StudyViewViolinPlot'; import { ClinicalViolinPlotBoxData, ClinicalViolinPlotRowData, } from 'cbioportal-ts-api-client'; import { ChartDimension, STUDY_VIEW_CONFIG, } from 'pages/studyView/StudyViewConfig'; import { toFixedWithoutTrailingZeros } from 'shared/lib/FormatUtils'; import { SortDirection } from 'react-virtualized'; import { EditableSpan } from 'cbioportal-frontend-commons'; import { getDataX, getTickValues, getViolinX, renderTooltipForBoxPlot, renderTooltipForPoint, violinPlotXPadding, } from 'pages/studyView/charts/violinPlotTable/StudyViewViolinPlotUtils'; import { ClinicalViolinPlotIndividualPoint } from 'cbioportal-ts-api-client'; import classnames from 'classnames'; import styles from 'pages/resultsView/survival/styles.module.scss'; import chartStyles from 'pages/studyView/charts/styles.module.scss'; import { Popover } from 'react-bootstrap'; import * as ReactDOM from 'react-dom'; import autobind from 'autobind-decorator'; import { getSampleViewUrl, getStudySummaryUrl } from 'shared/api/urls'; import LabeledCheckbox from 'shared/components/labeledCheckbox/LabeledCheckbox'; import tableStyles from '../../table/tables.module.scss'; import { clamp } from 'shared/lib/NumberUtils'; import LoadingIndicator from 'shared/components/loadingIndicator/LoadingIndicator'; const NUM_SAMPLES_COL_NAME = '#'; const HEADER_HEIGHT = 50; const ROWS_Y_START = 72; const FIRST_COLUMN_WIDTH = 120; const VIOLIN_PLOT_START_X = 140; export interface IStudyViewViolinPlotTableProps { width: number; height: number; dimension: ChartDimension; categoryColumnName: string; violinColumnName: string; violinBounds: { min: number; max: number; }; violinFilterRange?: { min?: number; max?: number; }; rows: ClinicalViolinPlotRowData[]; showViolin: boolean; showBox: boolean; logScale: boolean; selectedCategories: string[]; setFilters: ( type: 'categorical' | 'numerical', values: string[] | { start: number; end: number } ) => void; isLoading: boolean; } @observer export default class StudyViewViolinPlotTable extends React.Component< IStudyViewViolinPlotTableProps, {} > { private ref: any; @observable categoryColumnDragging = { _extraCategoryColumnWidth: 0, dragging: false, mouseXStart: -1, _extraWidthStart: 0, }; constructor(props: any) { super(props); makeObservable(this); } @computed get extraCategoryColumnWidth() { // only allow growing the column so that it shrinks the violin column by half return clamp( this.categoryColumnDragging._extraCategoryColumnWidth, 0, this.violinColumnWidthWithoutColumnResize / 2 ); } @autobind private setRef(r: any) { this.ref = r; } @observable rangeSelection = { mouseX: undefined as undefined | number, startX: undefined as undefined | number, dragging: false, get rectX() { return Math.min(this.mouseX, this.startX); }, get rectWidth() { return Math.abs(this.mouseX - this.startX); }, }; @observable tooltipModel: { point?: ClinicalViolinPlotIndividualPoint; boxData?: ClinicalViolinPlotBoxData; category: string; mouseX: number; mouseY: number; } | null = null; private tooltipResetTimer: any = null; private mouseInTooltip = false; @autobind private onMouseEnterTooltip() { this.mouseInTooltip = true; } @autobind private onMouseLeaveTooltip() { this.mouseInTooltip = false; } @observable sampleNumberFilter = '10'; @action.bound private setSampleNumberFilter(s: string) { this.sampleNumberFilter = s; } private violinX(v: number) { return getViolinX(v, this.props.violinBounds, this.violinPlotWidth); } @computed get violinColumnWidthWithoutColumnResize() { const baseViolinWidth = 160; //180; let additionalViolinWidth = 0; if (this.props.dimension.w > 3) { // starting after grid width 3, we will absorb // all grid width increases directly into the violin plot additionalViolinWidth = (this.props.dimension.w - 3) * STUDY_VIEW_CONFIG.layout.grid.w; } return baseViolinWidth + additionalViolinWidth; } @computed get violinColumnWidth() { return ( this.violinColumnWidthWithoutColumnResize - this.extraCategoryColumnWidth ); } @computed get violinPlotWidth() { return this.violinColumnWidth - 2 * violinPlotXPadding; } @action.bound private onMouseOverBoxPlot( boxData: ClinicalViolinPlotBoxData, mouseX: number, mouseY: number, category: string ) { // mouse on point means mouse not in tooltip this.onMouseLeaveTooltip(); // cancel hiding the tooltip this.cancelResetTooltip(); // Only update tooltip if this is not the same data as we're already showing if ( !this.tooltipModel || this.tooltipModel.category !== category || !_.isEqual(this.tooltipModel.boxData, boxData) ) { this.tooltipModel = { boxData, category, mouseX, mouseY }; } } @action.bound private onMouseOverPoint( point: ClinicalViolinPlotIndividualPoint, mouseX: number, mouseY: number, category: string ) { // mouse on point means mouse not in tooltip this.onMouseLeaveTooltip(); // cancel hiding the tooltip this.cancelResetTooltip(); // Only update tooltip if this is not the same data as we're already showing if ( !this.tooltipModel || this.tooltipModel.category !== category || !_.isEqual(this.tooltipModel.point, point) ) { this.tooltipModel = { point, category, mouseX, mouseY }; } } @action.bound private onMouseOverBackground() { // mouse hovering background means mouse not in tooltip this.onMouseLeaveTooltip(); if (this.tooltipResetTimer === null) { // add hide timer if it's not already pending this.tooltipResetTimer = setTimeout(() => { this.tooltipResetTimer = null; if (!this.mouseInTooltip) { this.tooltipModel = null; } }, 400); } } private cancelResetTooltip() { clearTimeout(this.tooltipResetTimer); this.tooltipResetTimer = null; } private isCategorySelected(category: string) { return category in this.selectedCategories; } @action.bound private onSelectCategory(category: string) { if (this.props.selectedCategories.includes(category)) { this.props.setFilters( 'categorical', this.props.selectedCategories.filter(x => x !== category) ); } else { this.props.setFilters('categorical', [ category, ...this.props.selectedCategories, ]); } } @action.bound private onSelectRange(range: { start: number; end: number }) { this.props.setFilters('numerical', range); } @action.bound private startCategoryColumnDrag(e: any) { e.stopPropagation(); e.preventDefault(); this.categoryColumnDragging.dragging = true; this.categoryColumnDragging.mouseXStart = e.clientX; this.categoryColumnDragging._extraWidthStart = this.extraCategoryColumnWidth; } @action.bound private doCategoryColumnDrag(e: any) { if (!this.categoryColumnDragging.dragging) { return; } this.categoryColumnDragging._extraCategoryColumnWidth = this.categoryColumnDragging._extraWidthStart + e.clientX - this.categoryColumnDragging.mouseXStart; } @action.bound private stopCategoryColumnDrag(e: any) { if (this.categoryColumnDragging.dragging) { e.stopPropagation(); e.preventDefault(); } this.categoryColumnDragging.dragging = false; } @action.bound private executeRangeSelection() { if (this.rangeSelection.rectWidth > 5) { // execute zoom let start = clamp( getDataX( this.rangeSelection.rectX - this.violinPlotStartX, this.props.violinBounds, this.violinPlotWidth ), this.props.violinBounds.min, this.props.violinBounds.max ); let end = clamp( getDataX( this.rangeSelection.rectX + this.rangeSelection.rectWidth - this.violinPlotStartX, this.props.violinBounds, this.violinPlotWidth ), this.props.violinBounds.min, this.props.violinBounds.max ); if (this.props.logScale) { start = Math.exp(start) - 1; end = Math.exp(end) - 1; } this.onSelectRange({ start, end }); } } @action.bound private stopRangeSelectionDrag(e: any) { this.rangeSelection.mouseX = undefined; this.rangeSelection.dragging = false; } @computed get selectedCategories() { return _.keyBy(this.props.selectedCategories); } @computed get columns() { return [ { name: this.props.categoryColumnName, render: (row: ClinicalViolinPlotRowData) => ( {row.category} ), headerRender: () => { return (