import * as React from 'react'; import { observer } from 'mobx-react'; import { computed, makeObservable, observable } from 'mobx'; import autobind from 'autobind-decorator'; import { ComparisonGroup } from '../GroupComparisonUtils'; import * as d3 from 'd3'; import _ from 'lodash'; import MemoizedHandlerFactory from '../../../shared/lib/MemoizedHandlerFactory'; import measureText from 'measure-text'; import * as ReactDOM from 'react-dom'; import { Popover } from 'react-bootstrap'; import classnames from 'classnames'; import styles from '../../resultsView/survival/styles.module.scss'; import { pluralize } from 'cbioportal-frontend-commons'; import { blendColors, getExcludedIndexes, getTextColor, joinGroupNames, regionIsSelected, toggleRegionSelected, } from '../OverlapUtils'; import { computeRectangleVennLayout, getRegionLabelPosition } from './layout'; import { adjustSizesForMinimumSizeRegions, scaleAndCenterLayout, } from './normalizeLayout'; export interface IRectangleVennDiagramProps { uid: string; groups: { uid: string; cases: string[]; }[]; // either 2 or 3 x: number; y: number; width: number; height: number; uidToGroup: { [uid: string]: ComparisonGroup }; onChangeSelectedRegions: (selectedRegions: number[][]) => void; selection: { regions: number[][]; // we do it like this so that updating it doesn't update all props }; caseType: 'sample' | 'patient'; onLayoutFailure: () => void; } function regionMouseOver(e: any) { e.target.setAttribute('fill', e.target.getAttribute('data-hover-fill')); } function regionMouseOut(e: any) { e.target.setAttribute('fill', e.target.getAttribute('data-default-fill')); } @observer export default class RectangleVennDiagram extends React.Component< IRectangleVennDiagramProps, {} > { @observable.ref svg: SVGElement | null = null; @observable.ref tooltipModel: { combination: number[]; sizeOfRegion: number; } | null = null; @observable mousePosition = { x: 0, y: 0 }; constructor(props: IRectangleVennDiagramProps) { super(props); makeObservable(this); } private regionClickHandlers = MemoizedHandlerFactory( (e: any, params: { combination: number[] }) => { this.props.onChangeSelectedRegions( toggleRegionSelected( params.combination, this.props.selection.regions ) ); } ); private regionHoverHandlers = MemoizedHandlerFactory( ( e: React.MouseEvent, params: { combination: number[]; sizeOfRegion: number } ) => { if (params.sizeOfRegion > 0) { this.tooltipModel = params; this.mousePosition.x = e.pageX; this.mousePosition.y = e.pageY; } } ); @autobind private svgRef(_svg: SVGElement | null) { this.svg = _svg; } @computed get layoutParams() { // First generate regions let combinations: number[][]; // in painting order so that mouse interactions look right switch (this.props.groups.length) { case 2: // all combinations of two groups combinations = [[0], [1], [0, 1]]; break; case 3: default: // all combinations of three groups combinations = [ [0], [1], [2], [0, 1], [0, 2], [1, 2], [0, 1, 2], ]; break; } // `regions` corresponds to exclusive regions in the diagram. // For example, in a diagram with sets A and B, there's a region for the intersection of A and B, // a region for whats only in A, and a region for whats only in B. const regions = combinations.map(combination => { // compute the cases in this region let casesInRegion = _.intersection( ...combination.map(index => this.props.groups[index].cases) ); const sizeOfIntersectionOfSets = casesInRegion.length; for (const i of getExcludedIndexes( combination, this.props.groups.length )) { _.pullAll(casesInRegion, this.props.groups[i].cases); } return { combination, sizeOfIntersectionOfSets, sizeOfRegion: casesInRegion.length, // compute the fill based on the colors of the included groups color: blendColors( combination.map( index => this.props.uidToGroup[this.props.groups[index].uid] .color ) ), labelPosition: null as { x: number; y: number } | null, }; }); // convert to form thats useful for algorithm const regionsInAlgorithmForm = regions.map(r => ({ sets: r.combination.map(i => this.props.groups[i].uid), size: r.sizeOfRegion, sizeOfIntersectionOfSets: r.sizeOfIntersectionOfSets, })); // the algorithm also needs all the sets const sets = this.props.groups.map(g => ({ size: g.cases.length, uid: g.uid, color: this.props.uidToGroup[g.uid].color, disjoint: (function() { const region = regionsInAlgorithmForm.find( r => r.sets.length === 1 && r.sets[0] === g.uid ); return region!.size === region!.sizeOfIntersectionOfSets; // disjoint if not intersecting with anything else, aka nothing is excluded from its solo region })(), })); const algorithmInput = adjustSizesForMinimumSizeRegions( regionsInAlgorithmForm, sets ); // compute a layout for the venn diagram using the specification given by the // sets and the regions const unscaledLayout = computeRectangleVennLayout( algorithmInput.regions, algorithmInput.sets, { maxIterations: 10000, } ); // scale and center the layout in a normalized form for presenting const padding = 20; const rectangles = scaleAndCenterLayout( unscaledLayout.rectangles, this.props.width, this.props.height, padding ); // compute label positions for nonempty regions for (const region of regions) { if (region.sizeOfRegion > 0) { region.labelPosition = getRegionLabelPosition( region.combination.map(i => this.props.groups[i].uid), rectangles ); } } const layoutFailure = unscaledLayout.finalErrorValue >= 1; if (layoutFailure) { setTimeout(this.props.onLayoutFailure, 0); // call in timeout so that no chance of breaking mobx invariant } return { rectangles, regions, sets, }; } @computed get svgElements() { // Compute all of the display elements of the venn diagram - the outline, the fills, and the text const CORNER_RADIUS = 8; const rectangleParams = this.layoutParams.rectangles; // compute the clip paths corresponding to each group circle const clipPaths = this.props.groups.map((group, index) => ( )); const elements = _.flattenDeep( this.layoutParams.regions.map(region => { // FOR EACH REGION: generate the filled region, and the "# cases" text for it // Each region is specified by the combination of groups corresponding to it const comb = region.combination; // generate clipped hover region by iteratively nesting a rect in all the clip paths // corresponding to groups in this region. This clips out, one by one, everything // except for the intersection. let hoverRegion: JSX.Element = ( 0 ? 'pointer' : 'default', }} data-default-fill={region.color} data-hover-fill={d3 .hsl(region.color) .brighter(1) .rgb()} onMouseOver={ region.sizeOfRegion > 0 ? regionMouseOver : undefined } onMouseOut={ region.sizeOfRegion > 0 ? regionMouseOut : undefined } onMouseMove={this.regionHoverHandlers({ combination: comb, sizeOfRegion: region.sizeOfRegion, })} onClick={ region.sizeOfRegion > 0 ? this.regionClickHandlers({ combination: comb, }) : undefined } data-test={`${ this.props.caseType }${region.combination.join(',')}VennRegion`} /> ); for (const index of comb) { hoverRegion = ( {hoverRegion} ); } return [hoverRegion]; }) ); const outlines = _.sortBy(this.layoutParams.sets, s => -s.size).map( s => { // draw the outlines of the rectangles const uid = s.uid; return ( ); } ); const textElements = _.flattenDeep( this.layoutParams.regions.map(region => { if (region.sizeOfRegion === 0 || !region.labelPosition) { return []; } const selected = regionIsSelected( region.combination, this.props.selection.regions ); const textContents = `${region.sizeOfRegion.toString()}${ selected ? ' ' + String.fromCharCode(10004) : '' }`; // Add rect behind for ease of reading const textSize = measureText({ text: textContents, fontFamily: 'Arial', fontSize: '13px', lineHeight: 1, }); const padding = 4; const textPosition = region.labelPosition; let textBackground = null; if (selected) { textBackground = ( ); } return [ textBackground, 0 ? 'bold' : 'normal'} pointerEvents="none" textAnchor="middle" alignmentBaseline="middle" data-test={`${ this.props.caseType }${region.combination.join(',')}VennLabel`} > {textContents} , ]; }) ); return ( <> {clipPaths} {elements} {outlines} {textElements} ); } @autobind private resetSelection() { this.props.onChangeSelectedRegions([]); } @autobind private resetTooltip() { this.tooltipModel = null; } @computed get tooltipContent() { if (!this.tooltipModel) { return null; } const includedGroupUids = this.tooltipModel.combination.map( groupIndex => this.props.groups[groupIndex].uid ); const includedGroups = includedGroupUids.map( uid => this.props.uidToGroup[uid] ); return (
{this.tooltipModel.sizeOfRegion}{' '} {pluralize( this.props.caseType, this.tooltipModel.sizeOfRegion )}
in only {joinGroupNames(includedGroups, 'and')}.
); } render() { return ( {this.svgElements} {!!this.tooltipModel && (ReactDOM as any).createPortal( {this.tooltipContent} , document.body )} ); } }