higlass
Version:
HiGlass Hi-C / genomic / large data viewer
579 lines (490 loc) • 15.7 kB
JSX
// @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;