UNPKG

higlass

Version:

HiGlass Hi-C / genomic / large data viewer

579 lines (490 loc) 15.7 kB
// @ts-nocheck import clsx from 'clsx'; import { brush, brushX, brushY } from 'd3-brush'; import { select } from 'd3-selection'; import PropTypes from 'prop-types'; import React from 'react'; import TrackControl from './TrackControl'; // Utils import { IS_TRACK_RANGE_SELECTABLE, or, resetD3BrushStyle } from './utils'; // Styles import styles from '../styles/CenterTrack.module.scss'; import stylesTrack from '../styles/Track.module.scss'; const STYLES = { pointerEvents: 'all', }; function sourceEvent(event) { return event?.sourceEvent; } class CenterTrack extends React.Component { constructor(props) { super(props); this.state = { isVisible: false, }; this.brushBehaviorX = brushX() .on('brush', this.brushedX.bind(this)) .on('end', this.brushedXEnded.bind(this)); this.brushBehaviorY = brushY() .on('brush', this.brushedY.bind(this)) .on('end', this.brushedYEnded.bind(this)); this.brushBehaviorXY = brush() .on('start', this.brushStarted.bind(this)) .on('brush', this.brushedXY.bind(this)) .on('end', this.brushedXYEnded.bind(this)); } /* -------------------------- Life Cycle Methods -------------------------- */ componentDidMount() { if (this.props.isRangeSelectionActive) { this.addBrush2d(); } } shouldComponentUpdate(nextProps, nextState) { if (this.rangeSelectionTriggeredXY) { this.rangeSelectionTriggeredXY = false; if (this.rangeSelectionTriggeredXYEnd) { const dim1 = nextProps.rangeSelection[0] || null; this.moveBrushXY([dim1, nextProps.rangeSelection[1]], true); this.rangeSelectionTriggeredXYEnd = false; } return this.state !== nextState; } if (this.props.rangeSelection !== nextProps.rangeSelection) { const dim1 = nextProps.rangeSelection[0] || null; if (this.props.is1dRangeSelection) { if (!this.rangeSelectionTriggeredX) { this.moveBrushX(dim1, nextProps.rangeSelectionEnd); } if (this.rangeSelectionTriggeredXEnd) { this.moveBrushX(dim1, nextProps.rangeSelectionEnd, true); } if (!this.rangeSelectionTriggeredY) { this.moveBrushY(dim1, nextProps.rangeSelectionEnd); } if (this.rangeSelectionTriggeredYEnd) { this.moveBrushY(dim1, nextProps.rangeSelectionEnd, true); } this.rangeSelectionTriggeredX = false; this.rangeSelectionTriggeredXEnd = false; this.rangeSelectionTriggeredY = false; this.rangeSelectionTriggeredYEnd = false; } else { this.moveBrushXY( [dim1, nextProps.rangeSelection[1]], nextProps.rangeSelectionEnd, ); } const isUnset = this.props.is1dRangeSelection && !nextProps.is1dRangeSelection && dim1 === null; return this.state !== nextState || isUnset; } return true; } componentDidUpdate() { if (this.props.isRangeSelectionActive) { this.addBrush2d(); } else { this.removeBrush1d(); this.removeBrush2d(); } } /* ---------------------------- Custom Methods ---------------------------- */ addBrush1d() { if ( !this.brushElX || !this.brushElY || (this.brushElXOld === this.brushElX && this.brushElYOld === this.brushElY) ) { return; } if (this.brushElXOld) { // Remove event listener on old element to avoid memory leaks this.brushElXOld.on('.brush', null); } if (this.brushElYOld) { // Remove event listener on old element to avoid memory leaks this.brushElYOld.on('.brush', null); } this.brushElX.call(this.brushBehaviorX); this.brushElY.call(this.brushBehaviorY); resetD3BrushStyle( this.brushElX, stylesTrack['track-range-selection-group-brush-selection'], ); resetD3BrushStyle( this.brushElY, stylesTrack['track-range-selection-group-brush-selection'], ); this.brushElXOld = this.brushElX; this.brushElYOld = this.brushElY; this.brushIs1dBound = true; } addBrush2d() { if (!this.brushElXY || this.brushElXYOld === this.brushElXY) { return; } if (this.brushElXYOld) { // Remove event listener on old element to avoid memory leaks this.brushElXYOld.on('.brush', null); } this.brushElXY.call(this.brushBehaviorXY); this.brushElXYOld = this.brushElXY; this.brushIs2dBound = true; resetD3BrushStyle( this.brushElXY, stylesTrack['track-range-selection-group-brush-selection'], ); } brushedX(event) { // Need to reassign variable to check after reset const rangeSelectionMoved = this.rangeSelectionMoved; this.rangeSelectionMoved = false; if ( !sourceEvent(event) || !this.props.onRangeSelectionX || !this.props.is1dRangeSelection || rangeSelectionMoved ) return; this.rangeSelectionTriggeredX = true; this.props.onRangeSelectionX(event.selection); } brushedXEnded(event) { const rangeSelectionMovedEnd = this.rangeSelectionMovedEnd; this.rangeSelectionMovedEnd = false; if ( !sourceEvent(event) || !this.props.onRangeSelectionX || !this.props.is1dRangeSelection || rangeSelectionMovedEnd ) return; this.rangeSelectionTriggeredX = true; this.rangeSelectionTriggeredXEnd = true; this.props.onRangeSelectionXEnd(event.selection); } brushedY(event) { // Need to reassign variable to check after reset const rangeSelectionMoved = this.rangeSelectionMoved; this.rangeSelectionMoved = false; if ( !sourceEvent(event) || !this.props.onRangeSelectionY || !this.props.is1dRangeSelection || rangeSelectionMoved ) return; this.rangeSelectionTriggeredY = true; this.props.onRangeSelectionY(event.selection); } brushedYEnded(event) { const rangeSelectionMovedEnd = this.rangeSelectionMovedEnd; this.rangeSelectionMovedEnd = false; if ( !sourceEvent(event) || !this.props.onRangeSelectionY || !this.props.is1dRangeSelection || rangeSelectionMovedEnd ) return; this.rangeSelectionTriggeredY = true; this.rangeSelectionTriggeredYEnd = true; this.props.onRangeSelectionYEnd(event.selection); } brushedXY(event) { // Need to reassign variable to check after reset const rangeSelectionMoved = this.rangeSelectionMoved; this.rangeSelectionMoved = false; if ( !sourceEvent(event) || !this.props.onRangeSelectionXY || rangeSelectionMoved || this.props.is1dRangeSelection ) return; this.rangeSelectionTriggeredXY = true; this.props.onRangeSelectionXY([ [event.selection[0][0], event.selection[1][0]], [event.selection[0][1], event.selection[1][1]], ]); } brushedXYEnded(event) { if (this.props.is1dRangeSelection) return; const rangeSelectionMovedEnd = this.rangeSelectionMovedEnd; this.rangeSelectionMovedEnd = false; // Brush end event with a selection if ( event.selection && event.sourceEvent && this.props.onRangeSelectionXY && !rangeSelectionMovedEnd ) { this.rangeSelectionTriggeredXY = true; this.rangeSelectionTriggeredXYEnd = true; this.props.onRangeSelectionXYEnd([ [event.selection[0][0], event.selection[1][0]], [event.selection[0][1], event.selection[1][1]], ]); } if (!event.selection) { this.rangeSelectionTriggeredXY = true; this.props.onRangeSelectionReset(); } } brushStarted(event) { if (!sourceEvent(event)) return; this.props.onRangeSelectionStart(); } moveBrushX(rangeSelection, animate = false) { if (!this.brushEl) { return; } if (this.brushIs2dBound) { this.removeBrush2d(); this.addBrush1d(); } const relRangeX = rangeSelection ? [ this.props.scaleX(rangeSelection[0]), this.props.scaleX(rangeSelection[1]), ] : null; this.rangeSelectionMoved = true; this.rangeSelectionMovedEnd = true; if (animate) { this.brushElX.transition().call(this.brushBehaviorX.move, relRangeX); } else { this.brushElX.call(this.brushBehaviorX.move, relRangeX); } } moveBrushY(rangeSelection, animate = false) { if (!this.brushEl) { return; } if (this.brushIs2dBound) { this.removeBrush2d(); this.addBrush1d(); } const relRangeY = rangeSelection ? [ this.props.scaleY(rangeSelection[0]), this.props.scaleY(rangeSelection[1]), ] : null; this.rangeSelectionMoved = true; this.rangeSelectionMovedEnd = true; if (animate) { this.brushElY.transition().call(this.brushBehaviorY.move, relRangeY); } else { this.brushElY.call(this.brushBehaviorY.move, relRangeY); } } moveBrushXY(rangeSelection, animate = false) { if (!this.brushEl) { return; } const relRange = [ [ this.props.scaleX(rangeSelection[0][0]), this.props.scaleY(rangeSelection[1][0]), ], [ this.props.scaleX(rangeSelection[0][1]), this.props.scaleY(rangeSelection[1][1]), ], ]; this.rangeSelectionMoved = true; this.rangeSelectionMovedEnd = true; if (animate) { this.brushElXY.transition().call(this.brushBehaviorXY.move, relRange); } else { this.brushElXY.call(this.brushBehaviorXY.move, relRange); } } mouseEnterHandler() { if (this.props.isRangeSelectionActive) return; this.setState({ isVisible: true, }); } mouseLeaveHandler() { this.setState({ isVisible: false, }); } removeBrush1d() { if (!this.brushIs1dBound) { return; } if (this.brushElX) { // Reset brush selection this.brushElX.call(this.brushBehaviorX.move, null); // Remove brush behavior this.brushElX.on('.brush', null); } if (this.brushElY) { // Reset brush selection this.brushElY.call(this.brushBehaviorY.move, null); // Remove brush behavior this.brushElY.on('.brush', null); } this.brushIs1dBound = false; } removeBrush2d() { if (!this.brushIs2dBound) { return; } if (this.brushElXY) { // Reset brush selection this.brushElXY.call(this.brushBehaviorXY.move, null); // Remove brush behavior this.brushElXY.on('.brush', null); this.brushElXYOld = undefined; this.brushIs2dBound = false; if (!this.props.is1dRangeSelection) this.props.onRangeSelectionReset(); } } /* ------------------------------ Rendering ------------------------------- */ render() { const isBrushable = this.props.tracks .map((track) => IS_TRACK_RANGE_SELECTABLE(track)) .reduce(or, false); // Althought the tracks property is an array and could contain more than one // track, in practice there is only one combined track. const menuClash = this.props.tracks.some((track) => { if (track.contents) { // if this is a combined track, iterate over children return track.contents.some((subTrack) => { if (subTrack.type === 'heatmap') { return subTrack.options.colorbarPosition === 'topRight'; } return false; }); } // if this isn't a combined track, just check if this a heatmap // with a topright colorbar if (track.type === 'heatmap') { return track.options.colorbarPosition === 'topRight'; } return false; }); let rangeSelectorName = 'track-range-selection'; if (this.props.isRangeSelectionActive) { rangeSelectorName += this.props.is1dRangeSelection ? '-active-secondary' : '-active-primary'; } const rangeSelectorGroup1dClass = clsx( !this.props.is1dRangeSelection && stylesTrack['track-range-selection-group-inactive'], ); const rangeSelectorGroup2dClass = clsx( this.props.is1dRangeSelection && stylesTrack['track-range-selection-group-inactive'], ); return ( <div className={clsx(this.props.className, styles['center-track'])} onMouseEnter={this.mouseEnterHandler.bind(this)} onMouseLeave={this.mouseLeaveHandler.bind(this)} style={{ height: this.props.height, width: this.props.width, }} > {isBrushable && ( <svg className={stylesTrack[rangeSelectorName]} style={{ height: this.props.height, width: this.props.width, }} xmlns="http://www.w3.org/2000/svg" > <title>Range Selection Brush</title> <g ref={(el) => { this.brushElX = select(el); }} className={rangeSelectorGroup1dClass} /> <g ref={(el) => { this.brushElY = select(el); }} className={rangeSelectorGroup1dClass} /> <g ref={(el) => { this.brushElXY = select(el); }} className={rangeSelectorGroup2dClass} /> </svg> )} {this.props.editable && ( // show track controls if config menu is visible or // mouse is within the bounds of the track <TrackControl // Whether the button to expand or collapse the track is available // for use. Only available in horizontal and vertical tracks and not // center. expandCollapseAvailable={false} imgStyleAdd={STYLES} imgStyleClose={STYLES} imgStyleMove={STYLES} imgStyleSettings={STYLES} isMoveable={false} isVisible={ this.state.isVisible || this.props.uid === this.props.configTrackMenuId } onAddSeries={this.props.onAddSeries} onCloseTrackMenuOpened={this.props.onCloseTrackMenuOpened} onConfigTrackMenuOpened={this.props.onConfigTrackMenuOpened} paddingRight={menuClash} uid={this.props.uid} /> )} </div> ); } } CenterTrack.defaultProps = { className: 'center-track', configTrackMenuId: null, is1dRangeSelection: false, rangeSelectionEnd: PropTypes.bool, isRangeSelectionActive: false, scaleX: (x) => x, scaleY: (x) => x, }; CenterTrack.propTypes = { className: PropTypes.string, configTrackMenuId: PropTypes.string, editable: PropTypes.bool, height: PropTypes.number.isRequired, is1dRangeSelection: PropTypes.bool, isRangeSelectionActive: PropTypes.bool, onAddSeries: PropTypes.func.isRequired, onCloseTrackMenuOpened: PropTypes.func.isRequired, onConfigTrackMenuOpened: PropTypes.func.isRequired, onRangeSelectionX: PropTypes.func.isRequired, onRangeSelectionXEnd: PropTypes.func.isRequired, onRangeSelectionY: PropTypes.func.isRequired, onRangeSelectionYEnd: PropTypes.func.isRequired, onRangeSelectionXY: PropTypes.func.isRequired, onRangeSelectionXYEnd: PropTypes.func.isRequired, onRangeSelectionReset: PropTypes.func.isRequired, onRangeSelectionStart: PropTypes.func.isRequired, rangeSelection: PropTypes.array.isRequired, rangeSelectionEnd: PropTypes.bool, scaleX: PropTypes.func, scaleY: PropTypes.func, tracks: PropTypes.array.isRequired, uid: PropTypes.string.isRequired, width: PropTypes.number.isRequired, }; export default CenterTrack;