higlass
Version:
HiGlass Hi-C / genomic / large data viewer
1,703 lines (1,452 loc) • 77 kB
JSX
// @ts-nocheck
import { ElementQueries, ResizeSensor } from 'css-element-queries';
import { pointer } from 'd3-selection';
import PropTypes from 'prop-types';
import React from 'react';
import ReactDOM from 'react-dom';
import slugid from 'slugid';
import AddTrackDialog from './AddTrackDialog';
import CenterTrack from './CenterTrack';
import CloseTrackMenu from './CloseTrackMenu';
import ConfigTrackMenu from './ConfigTrackMenu';
import ContextMenuContainer from './ContextMenuContainer';
// Components
import ContextMenuItem from './ContextMenuItem';
import CustomTrackDialog from './CustomTrackDialog';
import DragListeningDiv from './DragListeningDiv';
import GalleryTracks from './GalleryTracks';
import HorizontalTiledPlot from './HorizontalTiledPlot';
import PopupMenu from './PopupMenu';
import TrackRenderer from './TrackRenderer';
import VerticalTiledPlot from './VerticalTiledPlot';
import ViewContextMenu from './ViewContextMenu';
// import {HeatmapOptions} from './HeatmapOptions';
import withModal from './hocs/with-modal';
// Higher-order components
import withPubSub from './hocs/with-pub-sub';
import withTheme from './hocs/with-theme';
// Utils
import {
dataToGenomicLoci,
getTrackByUid,
getTrackPositionByUid,
isWithin,
sum,
visitPositionedTracks,
} from './utils';
// Configs
import {
DEFAULT_TRACKS_FOR_DATATYPE,
MOUSE_TOOL_SELECT,
TRACKS_INFO_BY_TYPE,
TRACK_LOCATIONS,
} from './configs';
import stylesCenterTrack from '../styles/CenterTrack.module.scss';
// Styles
import styles from '../styles/TiledPlot.module.scss';
export class TiledPlot extends React.Component {
constructor(props) {
super(props);
this.closing = false;
// that the tracks will be drawn on
const { tracks } = this.props;
this.canvasElement = null;
this.tracksByUidInit = {};
[
...(this.props.tracks.top || []),
...(this.props.tracks.right || []),
...(this.props.tracks.bottom || []),
...(this.props.tracks.left || []),
...(this.props.tracks.gallery || []),
...(this.props.tracks.center || []),
].forEach((track) => {
if (track.type === 'combined') {
// Damn this combined track...
track.contents.forEach((track2) => {
this.tracksByUidInit[track2.uid] = false;
});
} else {
this.tracksByUidInit[track.uid] = false;
}
});
this.xScale = null;
this.yScale = null;
this.addUidsToTracks(tracks);
// Add names to all the tracks
this.trackToReplace = null;
this.trackRenderer = null;
this.configTrackMenu = null;
/*
let trackOptions = this.props.editable ?
{'track': this.props.tracks.center[0].contents[0],
'configComponent': HeatmapOptions}
: null;
*/
// these values should be changed in componentDidMount
this.state = {
sizeMeasured: false,
height: 10,
width: 10,
tracks,
init: false,
addTrackPosition: null,
customDialog: null,
mouseOverOverlayUid: null,
// trackOptions: null
// trackOptions: trackOptions
forceUpdate: 0, // a random value that will be assigned by
// crucial functions to force an update
rangeSelection: [null, null],
rangeSelectionEnd: false,
chromInfo: null,
defaultChromSizes: null,
contextMenuCustomItems: null,
contextMenuPosition: null,
addDivisorDialog: null,
};
// This should be `true` until one tracks was added and initialized.
// The main difference to `this.state.init` is that `reset` should be reset
// to `true` when the user removes all tracks and starts with a blank view!
this.reset = true;
if (window.higlassTracksByType) {
// Extend `TRACKS_INFO_BY_TYPE` with the configs of plugin tracks.
Object.keys(window.higlassTracksByType).forEach((pluginTrackType) => {
TRACKS_INFO_BY_TYPE[pluginTrackType] =
window.higlassTracksByType[pluginTrackType].config;
});
}
// these dimensions are computed in the render() function and depend
// on the sizes of the tracks in each section
this.topHeight = 0;
this.bottomHeight = 0;
this.leftWidth = 0;
this.rightWidth = 0;
this.centerHeight = 0;
this.centerWidth = 0;
this.dragTimeout = null;
this.previousPropsStr = '';
this.contextMenuHandlerBound = this.contextMenuHandler.bind(this);
this.handleNoTrackAddedBound = this.handleNoTrackAdded.bind(this);
this.handleTracksAddedBound = this.handleTracksAdded.bind(this);
this.closeMenusBound = this.closeMenus.bind(this);
this.handleAddDivisorBound = this.handleAddDivisor.bind(this);
this.handleAddSeriesBound = this.handleAddSeries.bind(this);
this.handleChangeTrackDataBound = this.handleChangeTrackData.bind(this);
this.handleChangeTrackTypeBound = this.handleChangeTrackType.bind(this);
this.handleCloseTrackBound = this.handleCloseTrack.bind(this);
this.handleConfigureTrackBound = this.handleConfigureTrack.bind(this);
this.handleExportTrackDataBound = this.handleExportTrackData.bind(this);
this.handleLockValueScaleBound = this.handleLockValueScale.bind(this);
this.handleReplaceTrackBound = this.handleReplaceTrack.bind(this);
this.handleTrackOptionsChangedBound =
this.handleTrackOptionsChanged.bind(this);
this.handleUnlockValueScaleBound = this.handleUnlockValueScale.bind(this);
this.onAddTrack = this.handleAddTrack.bind(this);
}
waitForDOMAttachment(callback) {
if (!this.mounted) return;
const thisElement = ReactDOM.findDOMNode(this);
if (document.body.contains(thisElement)) {
callback();
} else {
requestAnimationFrame(() => this.waitForDOMAttachment(callback));
}
}
componentDidMount() {
this.mounted = true;
this.element = ReactDOM.findDOMNode(this);
this.canvasElement = ReactDOM.findDOMNode(this.props.canvasElement);
// new ResizeSensor(this.element, this.measureSize.bind(this));
this.waitForDOMAttachment(() => {
ElementQueries.listen();
this.resizeSensor = new ResizeSensor(
this.element.parentNode,
this.measureSize.bind(this),
);
this.measureSize();
});
// add event listeners for drag and drop events
this.addEventListeners();
// this.getDefaultChromSizes();
this.pubSubs = [];
this.pubSubs.push(
this.props.pubSub.subscribe('contextmenu', this.contextMenuHandlerBound),
);
}
UNSAFE_componentWillReceiveProps(newProps) {
this.addUidsToTracks(newProps.tracks);
this.setState({
tracks: newProps.tracks,
});
}
shouldComponentUpdate(nextProps, nextState) {
const thisPropsStr = this.previousPropsStr;
const nextPropsStr = this.updatablePropsToString(nextProps);
const thisStateStr = JSON.stringify(this.state);
const nextStateStr = JSON.stringify(nextState);
const toUpdate =
thisPropsStr !== nextPropsStr ||
thisStateStr !== nextStateStr ||
this.props.chooseTrackHandler !== nextProps.chooseTrackHandler ||
this.props.customDialog !== nextProps.customDialog;
if (toUpdate) this.previousPropsStr = nextPropsStr;
const numPrevTracks = this.numTracks;
this.numTracks = 0;
// Note that there is no point in running the code below with
// `this.props.tracks` and `nextProps.tracks` because the object is mutable
// and so the props of `this.props.tracks` and `nextProps.tracks` are always
// identical. To work around this we store the number of tracks in
// `this.numTracks`
visitPositionedTracks(this.props.tracks, () => this.numTracks++);
// With `this.reset ||` we ensure that subsequent updates do not unset the
// `this.reset = true`. Only `this.checkAllTilesetInfoReceived()` should set
// `this.reset` to `false`.
this.reset = this.reset || (numPrevTracks === 0 && this.numTracks > 0);
if (!this.numTracks) this.tracksByUidInit = {};
return toUpdate;
}
UNSAFE_componentWillUpdate() {
/**
* Need to determine the offset of this element relative to the canvas on which stuff
* will be drawn
*/
}
componentDidUpdate(prevProps, prevState) {
if (prevState.rangeSelection !== this.state.rangeSelection) {
let genomicRange = [null, null]; // Default range
if (
this.state.defaultChromSizes &&
this.state.rangeSelection.every((range) => range?.length)
) {
// Convert data into genomic loci
genomicRange = this.state.rangeSelection.map((range) =>
dataToGenomicLoci(...range, this.state.defaultChromSizes),
);
}
this.props.onRangeSelection({
dataRange: this.state.rangeSelection,
genomicRange,
});
}
if (this.state.customDialog || this.props.customDialog) {
const dialogData = this.state.customDialog || this.props.customDialog;
if (dialogData.length > 0) {
const componentArray = [];
const bodyPropsArray = [];
dialogData.forEach((dd) => {
componentArray.push(dd.bodyComponent);
bodyPropsArray.push(dd.bodyProps);
});
this.props.modal.open(
<CustomTrackDialog
// biome-ignore lint/correctness/noChildrenProp:
children={componentArray}
bodyProps={bodyPropsArray}
onCancel={this.props.closeCustomDialog}
title={dialogData[0].title}
/>,
);
}
}
if (prevProps.tracks.center !== this.props.tracks.center) {
// this.getDefaultChromSizes();
}
if (this.state.addTrackPosition || this.props.addTrackPosition) {
this.props.modal.open(
<AddTrackDialog
host={this.state.addTrackHost}
onCancel={this.handleNoTrackAddedBound}
onTracksChosen={this.handleTracksAddedBound}
position={this.state.addTrackPosition || this.props.addTrackPosition}
trackSourceServers={this.props.trackSourceServers}
/>,
);
}
if (this.state.addDivisorDialog) {
const series = this.state.addDivisorDialog;
this.props.modal.open(
<AddTrackDialog
datatype={TRACKS_INFO_BY_TYPE[series.type].datatype[0]}
host={this.state.addTrackHost}
onCancel={() => {
this.setState({ addDivisorDialog: null });
}}
onTracksChosen={(newTrack) => {
this.handleDivisorChosen(series, newTrack);
}}
trackSourceServers={this.props.trackSourceServers}
/>,
);
}
}
componentWillUnmount() {
this.closing = true;
this.removeEventListeners();
this.pubSubs.forEach((subscription) =>
this.props.pubSub.unsubscribe(subscription),
);
}
addUidsToTracks(positionedTracks) {
Object.keys(positionedTracks).forEach((position) => {
positionedTracks[position].forEach((track) => {
track.uid = track.uid || slugid.nice();
});
});
}
/*
getDefaultChromSizes() {
try {
const centralHeatmap = this.findCentralHeatmapTrack(
this.props.tracks.center
);
this.getChromInfo = chromInfo
.get(`${centralHeatmap.server}/chrom-sizes/?id=${centralHeatmap.tilesetUid}`)
.then(defaultChromSizes => this.setState({ defaultChromSizes }));
} catch (err) { }
}
*/
contextMenuHandler(e) {
if (!this.divTiledPlot) return;
const bBox = this.divTiledPlot.getBoundingClientRect();
const isClickWithin = isWithin(
e.clientX,
e.clientY,
bBox.left,
bBox.left + bBox.width,
bBox.top,
bBox.top + bBox.height,
);
if (!isClickWithin) return;
const mousePos = [e.clientX, e.clientY];
// Relative mouse position
const canvasMousePos = pointer(e, this.divTiledPlot);
// the x and y values of the rendered plots
// will be used if someone decides to draw a horizontal or vertical
// rule
const xVal = this.trackRenderer.zoomedXScale.invert(canvasMousePos[0]);
const yVal = this.trackRenderer.zoomedYScale.invert(canvasMousePos[1]);
let contextMenuCustomItems = null;
if (e.hgCustomItems) {
contextMenuCustomItems = e.hgCustomItems.map((item) => (
<ContextMenuItem key={item.key} onClick={item.onClick}>
{item.text}
</ContextMenuItem>
));
}
this.setState({
contextMenuCustomItems,
contextMenuPosition: {
left: mousePos[0],
top: mousePos[1],
canvasLeft: canvasMousePos[0] + this.trackRenderer.xPositionOffset,
canvasTop: canvasMousePos[1] + this.trackRenderer.yPositionOffset,
},
contextMenuDataX: xVal,
contextMenuDataY: yVal,
});
}
measureSize() {
if (this.element.clientWidth > 0 && this.element.clientHeight > 0) {
this.setState({
sizeMeasured: true,
width: this.element.clientWidth,
height: this.element.clientHeight,
});
}
}
handleTrackOptionsChanged(trackUid, newOptions) {
/**
* The drawing options for a track have changed.
*/
return this.props.onTrackOptionsChanged(trackUid, newOptions);
}
handleScalesChanged(x, y) {
this.xScale = x;
this.yScale = y;
this.props.onScalesChanged(x, y);
}
/**
* We've received information about a tileset from the server. Register it
* with the track definition.
* @param trackUid (string): The identifier for the track
* @param tilesetInfo (object): Information about the track (hopefully including
* its name.
*/
handleTilesetInfoReceived(trackUid, tilesetInfo) {
const track = getTrackByUid(this.props.tracks, trackUid);
if (!track) {
console.warn('Strange, track not found:', trackUid);
return;
}
this.tracksByUidInit[track.uid] = true;
this.checkAllTilesetInfoReceived();
if (!track.options) {
track.options = {};
}
// track.options.name = tilesetInfo.name;
track.name = tilesetInfo.name;
track.maxWidth = tilesetInfo.max_width;
track.transforms = tilesetInfo.transforms;
track.aggregationModes = tilesetInfo.aggregation_modes;
track.header = tilesetInfo.header;
track.binsPerDimension = tilesetInfo.bins_per_dimension;
if (tilesetInfo.resolutions) {
track.maxZoom = tilesetInfo.resolutions.length - 1;
track.resolutions = tilesetInfo.resolutions;
} else {
track.maxZoom = tilesetInfo.max_zoom;
}
track.coordSystem = tilesetInfo.coordSystem;
track.datatype = tilesetInfo.datatype;
}
/**
* Check if all track which are expecting a tileset info have been loaded.
*/
checkAllTilesetInfoReceived() {
// Do nothing if HiGlass initialized already
if (
(this.state.init && !this.reset) ||
!this.trackRenderer ||
!this.props.zoomToDataExtentOnInit()
) {
return;
}
// Get the total number of track that are expecting a tilesetInfo
const allTracksWithTilesetInfos = Object.keys(
this.trackRenderer.trackDefObjects,
)
// Map track to a list of tileset infos
.map((trackUuid) => {
const track = this.trackRenderer.trackDefObjects[trackUuid].trackObject;
if (track.childTracks) return track.childTracks;
return track;
})
// Needed because of combined tracks
.reduce((a, b) => a.concat(b), [])
// We distinguish between tracks that need a tileset info and those whoch
// don't by comparing `undefined` vs something else, i.e., tracks that
// need a tileset info will be initialized with `this.tilesetInfo = null;`.
.filter(
({ tilesetInfo }) =>
typeof tilesetInfo !== 'undefined' && tilesetInfo !== true,
);
// Only count tracks that are suppose to get a tileset
const loadedTilesetInfos = Object.values(this.tracksByUidInit).filter(
(x) => x,
).length;
if (allTracksWithTilesetInfos.length === loadedTilesetInfos) {
this.setState({ init: true });
this.reset = false;
this.handleZoomToData();
}
}
handleOverlayMouseEnter(uid) {
this.setState({
mouseOverOverlayUid: uid,
});
}
handleOverlayMouseLeave(uid) {
if (uid === this.state.mouseOverOverlayUid) {
this.setState({
mouseOverOverlayUid: null,
});
}
}
handleTrackPositionChosen(pTrack) {
this.setState({ mouseOverOverlayUid: null });
this.props.chooseTrackHandler(pTrack.track.uid);
}
handleNoTrackAdded() {
/*
* User hit cancel on the AddTrack dialog so we need to
* just close it and do nothin
*/
this.trackToReplace = null;
this.props.onNoTrackAdded();
this.setState({
addTrackPosition: null,
addTrackHost: null,
});
}
handleAddDivisor(series) {
this.setState({
addDivisorDialog: series,
});
}
/**
* The user has selected a track that they wish to use to normalize another
* track.
*/
handleDivisorChosen(series, newTrack) {
this.setState({
addDivisorDialog: null,
});
const numerator = series.data
? {
server: series.data.server,
tilesetUid: series.data.tilesetUid,
}
: {
server: series.server,
tilesetUid: series.tilesetUid,
};
const denominator = {
server: newTrack[0].server,
tilesetUid: newTrack[0].uuid,
};
this.handleChangeTrackData(series.uid, {
type: 'divided',
children: [numerator, denominator],
});
}
handleDivideSeries(seriesUid) {
/*
* We want to create a new series that consists of this series
* being divided by another. Useful for comparing two tracks
* by division.
*
* Will start working with just heatmaps and then progress to
* other track types.
*/
}
handleAddSeries(trackUid) {
const trackPosition = getTrackPositionByUid(this.props.tracks, trackUid);
const track = getTrackByUid(this.props.tracks, trackUid);
this.setState({
addTrackPosition: trackPosition,
addTrackHost: track,
});
}
handleReplaceTrack(uid, orientation) {
/**
* @param uid (string): The uid of the track to replace
* @param orientation (string): The place where to put the new track
*/
this.trackToReplace = uid;
this.handleAddTrack(orientation);
}
handleAddTrack(position) {
this.setState({
addTrackPosition: position,
addTrackHost: null,
});
}
handleResizeTrack(uid, width, height) {
const { tracks } = this.state;
for (const trackType in tracks) {
const theseTracks = tracks[trackType];
const filteredTracks = theseTracks.filter((d) => d.uid === uid);
if (filteredTracks.length > 0) {
filteredTracks[0].width = width;
filteredTracks[0].height = height;
}
}
this.setState({
tracks,
forceUpdate: Math.random(),
});
this.props.onResizeTrack();
}
closeMenus() {
this.setState({
closeTrackMenuId: null,
configTrackMenuId: null,
contextMenuPosition: null,
contextMenuCustomItems: null,
});
}
handleLockValueScale(uid) {
this.closeMenus();
this.props.onLockValueScale(uid);
}
handleUnlockValueScale(uid) {
this.closeMenus();
this.props.onUnlockValueScale(uid);
}
handleCloseTrack(uid) {
this.closeMenus();
this.props.onCloseTrack(uid);
}
handleChangeTrackType(uid, newType) {
// close the config track menu
this.closeMenus();
// change the track type
this.props.onChangeTrackType(uid, newType);
}
/**
* Change this tracks data section so that it
* is either of type "divided" or the "divided"
* type is removed
*/
handleChangeTrackData(uid, newData) {
this.closeMenus();
this.props.onChangeTrackData(uid, newData);
}
handleTracksAdded(newTracks, position, host) {
/**
* Arguments
* ---------
* newTracks: {object}
* The description of the track, including its type
* and data source.
* position: string
* Where to place this track
* host: track
* The existing track that we're adding the new one to
*
* Returns
* -------
*
* { uid: "", width: }:
* The trackConfig object describing this track. Essentially
* the newTrack object passed in with some extra information
* (such as the uid) added.
*/
if (this.trackToReplace) {
this.handleCloseTrack(this.trackToReplace);
this.trackToReplace = null;
}
// if host is defined, then we're adding a new series
// further down the chain a combined track will be created
this.props.onTracksAdded(newTracks, position, host);
this.setState({
addTrackPosition: null,
addTrackHost: null,
});
return newTracks;
}
handleCloseTrackMenuOpened(uid, clickPosition) {
this.setState({
closeTrackMenuId: uid,
closeTrackMenuLocation: clickPosition,
});
}
handleCloseContextMenu() {
this.setState({
contextMenuCustomItems: null,
contextMenuPosition: null,
contextMenuDataX: null,
contextMenuDataY: null,
});
}
handleCloseTrackMenuClosed() {
this.setState({
closeTrackMenuId: null,
});
}
handleConfigTrackMenuOpened(uid, clickPosition) {
// let orientation = getTrackPositionByUid(uid);
this.closeMenus();
this.setState({
configTrackMenuId: uid,
configTrackMenuLocation: clickPosition,
});
}
handleConfigureTrack(track, configComponent) {
this.setState({
configTrackMenuId: null,
trackOptions: { track, configComponent },
});
this.closeMenus();
}
handleSortEnd(sortedTracks) {
this.setState((prevState) => {
// some tracks were reordered in the list so we need to reorder them in the original
// dataset
const tracks = prevState.tracks;
// calculate the positions of the sortedTracks
const positions = {};
for (let i = 0; i < sortedTracks.length; i++) {
positions[sortedTracks[i].uid] = i;
}
for (const trackType in tracks) {
const theseTracks = tracks[trackType];
if (!theseTracks.length) {
continue;
}
if (theseTracks[0].uid in positions) {
const newTracks = new Array(theseTracks.length);
// this is the right track position
for (let i = 0; i < theseTracks.length; i++) {
newTracks[positions[theseTracks[i].uid]] = theseTracks[i];
}
tracks[trackType] = newTracks;
}
}
return {
tracks,
forceUpdate: Math.random(),
};
});
}
createTracksAndLocations() {
const tracksAndLocations = [];
const { tracks } = this.state;
TRACK_LOCATIONS.forEach((location) => {
if (tracks[location]) {
tracks[location].forEach((track) => {
if (track.contents) {
track.contents.forEach((content) => {
content.position = location;
});
}
// track.position is used in TrackRenderer to determine
// whether to use LeftTrackModifier
track.position = location;
tracksAndLocations.push({ track, location });
});
}
});
return tracksAndLocations;
}
/**
* Calculate where a track is absoluately positioned within the drawing area
*
* @param track: The track object (with members, e.g. track.uid, track.width,
* track.height)
* @param location: Where it's being plotted (e.g. 'top', 'bottom')
* @return: The position of the track and it's height and width
* (e.g. {left: 10, top: 20, width: 30, height: 40}
*/
calculateTrackPosition(track, location) {
let top = this.props.paddingTop;
let bottom = this.props.paddingBottom;
let left = this.props.paddingLeft;
let right = this.props.paddingRight;
let width = this.centerWidth;
let height = track.height;
let offsetX = 0;
let offsetY = 0;
switch (location) {
case 'top':
left += this.leftWidth;
for (let i = 0; i < this.state.tracks.top.length; i++) {
if (this.state.tracks.top[i].uid === track.uid) {
break;
}
top += this.state.tracks.top[i].height;
}
break;
case 'bottom':
left += this.leftWidth;
top += this.topHeight + this.centerHeight + this.galleryDim;
for (let i = 0; i < this.state.tracks.bottom.length; i++) {
if (this.state.tracks.bottom[i].uid === track.uid) {
break;
}
top += this.state.tracks.bottom[i].height;
}
break;
case 'left':
top += this.topHeight;
width = track.width;
height = this.centerHeight;
for (let i = 0; i < this.state.tracks.left.length; i++) {
if (this.state.tracks.left[i].uid === track.uid) {
break;
}
left += this.state.tracks.left[i].width;
}
break;
case 'right':
left += this.leftWidth + this.centerWidth + this.galleryDim;
top += this.topHeight;
width = track.width;
height = this.centerHeight;
for (let i = 0; i < this.state.tracks.right.length; i++) {
if (this.state.tracks.right[i].uid === track.uid) {
break;
}
left += this.state.tracks.right[i].width;
}
break;
case 'center':
left += this.leftWidth;
top += this.topHeight;
height = this.centerHeight;
break;
case 'gallery':
left += this.leftWidthNoGallery;
top += this.topHeightNoGallery;
width =
this.state.width -
this.leftWidthNoGallery -
this.rightWidthNoGallery -
this.props.paddingLeft;
height =
this.state.height -
this.topHeightNoGallery -
this.bottomHeightNoGallery -
this.props.paddingTop;
offsetX = this.galleryDim;
offsetY = this.galleryDim;
for (let i = 0; i < this.state.tracks.gallery.length; i++) {
if (this.state.tracks.gallery[i].uid === track.uid) {
break;
}
width -= 2 * this.state.tracks.gallery[i].height;
height -= 2 * this.state.tracks.gallery[i].height;
left += this.state.tracks.gallery[i].height;
top += this.state.tracks.gallery[i].height;
offsetX -= this.state.tracks.gallery[i].height;
offsetY -= this.state.tracks.gallery[i].height;
}
for (let i = 0; i < this.state.tracks.right.length; i++) {
right += this.state.tracks.right[i].width;
}
for (let i = 0; i < this.state.tracks.bottom.length; i++) {
bottom += this.state.tracks.bottom[i].height;
}
track.offsetX = offsetX;
track.offsetY = offsetY;
track.offsetTop = top;
track.offsetRight = right;
track.offsetBottom = bottom;
track.offsetLeft = left;
break;
// case 'whole':
default:
width = this.leftWidth + this.centerWidth + this.rightWidth;
height = this.topHeight + this.centerHeight + this.bottomHeight;
}
if (TRACK_LOCATIONS.indexOf(location) === -1) {
console.warn('Track with unknown position present:', location, track);
}
return {
left,
top,
width,
height,
track,
};
}
/**
* Find a central heatmap track among all displayed tracks
*
* @param {Array} tracks Tracks to be searched.
* @return {Object} The first central heatmap track or `undefined`.
*/
findCentralHeatmapTrack(tracks) {
for (let i = 0; i < tracks.length; i++) {
if (tracks[i].type === 'combined') {
return this.findCentralHeatmapTrack(tracks[i].contents);
}
if (tracks[i].type === 'heatmap') return tracks[i];
}
return undefined;
}
trackUuidToOrientation(trackUuid) {
/**
* Obtain the orientation of the track defined
* by the Uuid and return it.
*
* Parameters
* ----------
* trackUuid: 'xsdfsd'
*
* Returns
* -------
* orientation: '1d-horizontal'
*/
}
overlayTracks(positionedTracks) {
/**
* Return the current set of overlay tracks.
*
* These have no positions of their own because
* they depend on other tracks to be drawn first.
*
* Parameters
* ----------
* positionedTracks: The tracks along with their positions
*
* Returns
* -------
* overlaysWithOrientationsAndPositions: []
*
*/
if (this.props.overlays) {
const overlayDefs = this.props.overlays
.filter((overlayTrack) => overlayTrack.includes?.length)
.map((overlayTrack) => {
const type = overlayTrack.type
? `overlay-${overlayTrack.type}-track`
: 'overlay-track';
const overlayDef = {
...overlayTrack,
uid: overlayTrack.uid || slugid.nice(),
includes: overlayTrack.includes,
type,
options: Object.assign(overlayTrack.options, {
orientationsAndPositions: overlayTrack.includes
.map((trackUuid) => {
// translate a trackUuid into that track's orientation
const includedTrack = getTrackByUid(
this.props.tracks,
trackUuid,
);
if (!includedTrack) {
console.warn(
`OverlayTrack included uid (${trackUuid}) not found in the track list`,
);
return null;
}
const trackPos = getTrackPositionByUid(
this.props.tracks,
includedTrack.uid,
);
let orientation;
if (trackPos === 'top' || trackPos === 'bottom') {
orientation = '1d-horizontal';
}
if (trackPos === 'left' || trackPos === 'right') {
orientation = '1d-vertical';
}
if (trackPos === 'center') {
orientation = '2d';
}
if (!orientation) {
console.warn(
'Only top, bottom, left, right, or center tracks can be overlaid at the moment',
);
return null;
}
const positionedTrack = positionedTracks.filter(
(track) => track.track.uid === trackUuid,
);
if (!positionedTrack.length) {
// couldn't find a matching track, somebody must have included
// an invalid uuid
return null;
}
const position = {
left: positionedTrack[0].left - this.props.paddingLeft,
top: positionedTrack[0].top - this.props.paddingTop,
width: positionedTrack[0].width,
height: positionedTrack[0].height,
};
return {
orientation,
position,
};
})
.filter((x) => x), // filter out null entries
}),
};
// the 2 * verticalMargin is to make up for the space taken away
// in render(): this.centerHeight = this.state.height...
return {
top: this.props.paddingTop,
left: this.props.paddingLeft,
width: this.leftWidth + this.centerWidth + this.rightWidth,
height:
this.topHeight +
this.centerHeight +
this.bottomHeight +
this.props.marginTop +
this.props.marginBottom,
track: overlayDef,
};
});
return overlayDefs;
}
return [];
}
positionedTracks() {
/**
* Return the current set of tracks along with their positions
* and dimensions
*/
const tracksAndLocations = this.createTracksAndLocations().map(
({ track, location }) => this.calculateTrackPosition(track, location),
);
return tracksAndLocations;
}
createTrackPositionTexts() {
/**
* Create little text fields that show the position and width of
* each track, just to show that we can calculate that and pass
* it to the rendering context.
*/
const positionedTracks = this.positionedTracks();
this.createTracksAndLocations();
const trackElements = positionedTracks.map((trackPosition) => {
const { track } = trackPosition;
return (
<div
key={track.uid}
style={{
left: trackPosition.left,
top: trackPosition.top,
width: trackPosition.width,
height: trackPosition.height,
position: 'absolute',
}}
>
{track.uid.slice(0, 2)}
</div>
);
});
return trackElements;
}
handleExportTrackData(hostTrackUid, trackUid) {
/*
* Export the data present in a track. Whether a track can export data is defined
* in the track type definition in config.js
*/
const track = getTrackByUid(this.props.tracks, trackUid);
let trackObject = null;
if (hostTrackUid !== trackUid) {
// the track whose data we're trying to export is part of a combined track
trackObject =
this.trackRenderer.trackDefObjects[hostTrackUid].trackObject
.createdTracks[track.uid];
} else {
({ trackObject } = this.trackRenderer.trackDefObjects[hostTrackUid]);
}
trackObject.exportData();
this.closeMenus();
}
/**
* List all the tracks that are under this mouse position
*/
listTracksAtPosition(x, y, isReturnTrackObj = false) {
const trackObjectsAtPosition = [];
if (!this.trackRenderer) return [];
for (const uid in this.trackRenderer.trackDefObjects) {
const trackObj = this.trackRenderer.trackDefObjects[uid].trackObject;
if (trackObj.respondsToPosition(x, y)) {
// check if this track wishes to respond to events at position x,y
// by default, this is true
// it is false in tracks like the horizontal and vertical rule which only
// wish to be identified if the mouse is directly over them
if (isReturnTrackObj) {
if (this.props.tracks.center) {
if (this.props.tracks.center.contents) {
for (
let i = 0;
i < this.props.tracks.center.contents.length;
i++
) {
if (this.props.tracks.center.contents[i].uid === uid) {
trackObj.is2d = true;
}
}
} else if (
this.props.tracks.center?.length &&
this.props.tracks.center[0].uid === uid
) {
trackObj.is2d = true;
}
}
trackObjectsAtPosition.push(trackObj);
} else {
trackObjectsAtPosition.push(
this.trackRenderer.trackDefObjects[uid].trackDef.track,
);
}
}
}
return trackObjectsAtPosition;
}
listAllTrackObjects() {
/**
* Get a list of all the track objects in this
* view.
*
* These are the objects that do the drawing, not the track
* definitions in the viewconf.
*
* Returns
* -------
* trackObjects: []
* A list of the track objects in this view
*/
const trackObjectsToCheck = [];
for (const uid in this.trackRenderer.trackDefObjects) {
const tdo = this.trackRenderer.trackDefObjects[uid];
// if this is a combined track then we need to recurse into its
// subtracks
if (tdo.trackObject.createdTracks) {
for (const uid1 in tdo.trackObject.createdTracks) {
const trackObject = tdo.trackObject.createdTracks[uid1];
trackObjectsToCheck.push(trackObject);
}
} else {
trackObjectsToCheck.push(tdo.trackObject);
}
}
return trackObjectsToCheck;
}
handleZoomToData() {
/**
* Try to zoom in or out so that the bounds of the view correspond to the
* extent of the data.
*/
const maxSafeInt = Number.MAX_SAFE_INTEGER;
const minSafeInt = Number.MIN_SAFE_INTEGER;
const minPos = [maxSafeInt, maxSafeInt];
const maxPos = [minSafeInt, minSafeInt];
const trackObjectsToCheck = this.listAllTrackObjects();
// go through every track definition
for (const trackObject of trackObjectsToCheck) {
// get the minimum and maximum positions of all the subtracks
if (trackObject.tilesetInfo) {
if (trackObject.tilesetInfo.min_pos) {
for (let j = 0; j < trackObject.tilesetInfo.min_pos.length; j++) {
if (trackObject.tilesetInfo.min_pos[j] < minPos[j]) {
minPos[j] = trackObject.tilesetInfo.min_pos[j];
}
if (trackObject.tilesetInfo.max_pos[j] > maxPos[j]) {
maxPos[j] = trackObject.tilesetInfo.max_pos[j];
}
}
}
}
}
// set the initial domain
const left =
this.trackRenderer.currentProps.paddingLeft +
this.trackRenderer.currentProps.leftWidth;
let newXDomain = [
left,
left + this.trackRenderer.currentProps.centerWidth,
].map(
this.trackRenderer.zoomTransform.rescaleX(this.trackRenderer.xScale)
.invert,
);
const top =
this.trackRenderer.currentProps.paddingTop +
this.trackRenderer.currentProps.topHeight;
let newYDomain = [
top,
top + this.trackRenderer.currentProps.centerHeight,
].map(
this.trackRenderer.zoomTransform.rescaleY(this.trackRenderer.yScale)
.invert,
);
// reset the zoom transform
this.trackRenderer.zoomTransform.k = 1;
this.trackRenderer.zoomTransform.x = 0;
this.trackRenderer.zoomTransform.y = 0;
this.trackRenderer.applyZoomTransform();
if (
minPos[0] < Number.MAX_SAFE_INTEGER &&
maxPos[0] > Number.MIN_SAFE_INTEGER
) {
newXDomain = [minPos[0], maxPos[0]];
}
if (
minPos[1] < Number.MAX_SAFE_INTEGER &&
maxPos[1] > Number.MIN_SAFE_INTEGER
) {
newYDomain = [minPos[1], maxPos[1]];
}
this.props.onDataDomainChanged(newXDomain, newYDomain);
}
resetViewport() {
// Reset the zoom transform
this.trackRenderer.zoomTransform.k = 1;
this.trackRenderer.zoomTransform.x = 0;
this.trackRenderer.zoomTransform.y = 0;
this.trackRenderer.applyZoomTransform();
this.props.onDataDomainChanged(
this.props.initialXDomain,
this.props.initialYDomain,
);
}
updatablePropsToString(props) {
return JSON.stringify({
tracks: props.tracks,
overlays: props.overlays,
viewOptions: props.viewOptions,
uid: props.uid,
addTrackPosition: props.addTrackPosition,
editable: props.editable,
marginTop: props.marginTop,
marginBottom: props.marginBottom,
marginLeft: props.marginLeft,
marginRight: props.marginRight,
paddingTop: props.paddingTop,
paddingBottom: props.paddingBottom,
paddingLeft: props.paddingLeft,
paddingRight: props.paddingRight,
mouseTool: props.mouseTool,
initialXDomain: props.initialXDomain,
initialYDomain: props.initialYDomain,
trackSourceServers: props.trackSourceServers,
zoomable: props.zoomable,
draggingHappening: props.draggingHappening,
});
}
getXYScales() {
if (this.trackRenderer) {
this.xScale = this.trackRenderer.currentXScale;
this.yScale = this.trackRenderer.currentYScale;
}
}
/**
* Translate view to data location.
*
* @description
* The view location is in pixels relative to the browser window. The data
* location is given by the scaling relative to the initial x and y domains.
* And the genomic location depends on the chrom sizes.
*
* @method rangeViewToDataLoci
* @author Fritz Lekschas
* @date 2018-01-14
* @param {Array} range Selected view range
* @param {Function} scale View to data scaling
* @return {Array} 2D array of data locations
*/
rangeViewToDataLoci(range, scale) {
if (!scale) return [null, null];
return [
Number.parseInt(scale.invert(range[0]), 10),
Number.parseInt(scale.invert(range[1]), 10),
];
}
rangeSelectionResetHandler() {
if (this.state.rangeSelectionMaster) {
this.setState({
is1dRangeSelection: null,
rangeSelection: [null, null],
rangeSelectionMaster: null,
rangeSelectionEnd: false,
});
}
}
rangeSelection1dEndHandler(axis) {
if (!this.xScale || !this.yScale) {
this.getXYScales();
}
const scale = axis === 'x' ? this.xScale : this.yScale;
return (range) => {
this.setState((prevState) => {
const newRangeSelection = prevState.is1dRangeSelection
? [null, null]
: prevState.rangeSelection.slice();
const accessor = !this.state.is1dRangeSelection && axis === 'y' ? 1 : 0;
let dataPos = this.rangeViewToDataLoci(range, scale);
// Enforce range selection size constraints
const size = dataPos[1] - dataPos[0];
if (this.props.rangeSelection1dSize[0] > size) {
// Blow selection up
const center = dataPos[0] + size / 2;
dataPos = [
center - this.props.rangeSelection1dSize[0] / 2,
center + this.props.rangeSelection1dSize[0] / 2,
];
} else if (this.props.rangeSelection1dSize[1] < size) {
// Shrink selection
const center = dataPos[0] + size / 2;
dataPos = [
center - this.props.rangeSelection1dSize[1] / 2,
center + this.props.rangeSelection1dSize[1] / 2,
];
}
newRangeSelection[accessor] = dataPos;
if (this.props.rangeSelectionToInt) {
newRangeSelection[accessor] = newRangeSelection[accessor].map((x) =>
Math.round(x),
);
}
return {
rangeSelection: newRangeSelection,
rangeSelectionEnd: true,
};
});
};
}
rangeSelection1dHandler(axis) {
if (!this.xScale || !this.yScale) this.getXYScales();
const scale = axis === 'x' ? this.xScale : this.yScale;
return (range) => {
this.setState((prevState) => {
const newRangeSelection = prevState.is1dRangeSelection
? [null, null]
: prevState.rangeSelection.slice();
const accessor = !prevState.is1dRangeSelection && axis === 'y' ? 1 : 0;
newRangeSelection[accessor] = this.rangeViewToDataLoci(range, scale);
return {
rangeSelection: newRangeSelection,
rangeSelectionEnd: false,
};
});
};
}
rangeSelection1dStartHandler() {
if (!this.state.rangeSelectionMaster) {
this.setState({
is1dRangeSelection: true,
rangeSelectionMaster: true,
rangeSelectionEnd: false,
});
}
}
rangeSelection2dHandler(range) {
if (!this.xScale || !this.yScale) this.getXYScales();
this.setState({
rangeSelection: [
this.rangeViewToDataLoci(range[0], this.xScale),
this.rangeViewToDataLoci(range[1], this.yScale),
],
rangeSelectionEnd: false,
});
}
rangeSelection2dStartHandler() {
if (!this.state.rangeSelectionMaster) {
this.setState({
is1dRangeSelection: false,
rangeSelectionMaster: true,
rangeSelectionEnd: false,
});
}
}
rangeSelection2dEndHandler(range) {
if (!this.xScale || !this.yScale) this.getXYScales();
const dataPosX = this.rangeViewToDataLoci(range[0], this.xScale);
const dataPosY = this.rangeViewToDataLoci(range[1], this.yScale);
let dataPos = [dataPosX, dataPosY];
// Enforce range selection size constraints
const sizeX = dataPosX[1] - dataPosX[0];
const sizeY = dataPosY[1] - dataPosY[0];
const size = [sizeX, sizeY];
dataPos.forEach((pos, i) => {
if (this.props.rangeSelection1dSize[0] > size[i]) {
// Blow selection up
const center = pos[0] + Math.round(size[i] / 2);
pos[0] = center - this.props.rangeSelection1dSize[0] / 2;
pos[1] = center + this.props.rangeSelection1dSize[0] / 2;
} else if (this.props.rangeSelection1dSize[1] < size[i]) {
// Shrink selection
const center = pos[0] + Math.round(size[i] / 2);
pos[0] = center - this.props.rangeSelection1dSize[1] / 2;
pos[1] = center + this.props.rangeSelection1dSize[1] / 2;
}
});
if (this.props.rangeSelectionToInt) {
dataPos = dataPos.map((x) => x.map((y) => Math.round(y)));
}
this.setState({
rangeSelection: dataPos,
rangeSelectionEnd: true,
});
}
getContextMenu() {
if (this.state.contextMenuPosition) {
const relevantTracks = this.listTracksAtPosition(
this.state.contextMenuPosition.canvasLeft,
this.state.contextMenuPosition.canvasTop,
);
return (
<PopupMenu onMenuClosed={this.closeMenusBound}>
<ViewContextMenu
closeMenu={this.closeMenusBound}
coords={[this.state.contextMenuDataX, this.state.contextMenuDataY]}
customItems={this.state.contextMenuCustomItems}
onAddDivisor={this.handleAddDivisorBound}
onAddSeries={this.handleAddSeriesBound}
// Can only add one new track at a time
// because "whole" tracks are always drawn on top of each other,
// the notion of Series is unnecessary and so 'host' is null
onAddTrack={(newTrack) => {
this.props.onTracksAdded([newTrack], newTrack.position, null);
this.handleCloseContextMenu();
}}
onChangeTrackData={this.handleChangeTrackDataBound}
onChangeTrackType={this.handleChangeTrackTypeBound}
onCloseTrack={this.handleCloseTrackBound}
onConfigureTrack={this.handleConfigureTrackBound}
onExportData={this.handleExportTrackDataBound}
onLockValueScale={this.handleLockValueScaleBound}
onReplaceTrack={this.handleReplaceTrackBound}
onTrackOptionsChanged={this.handleTrackOptionsChangedBound}
onUnlockValueScale={this.handleUnlockValueScaleBound}
orientation="right"
position={this.state.contextMenuPosition}
theme={this.props.theme}
tracks={relevantTracks}
trackRenderer={this.trackRenderer}
trackSourceServers={this.props.trackSourceServers}
/>
</PopupMenu>
);
}
return null;
}
/**
* Draw an overlay that shows the positions of all the different
* track areas
*/
getIdealizedTrackPositionsOverlay() {
const evtJson = this.props.draggingHappening;
// For whatever reason, evtJson is sometimes a boolean. This rest of
// this code block assumes it's an object, so we return undefined if
// it's not.
if (typeof evtJson === 'boolean') {
return undefined;
}
const datatype = evtJson.datatype;
if (!(datatype in DEFAULT_TRACKS_FOR_DATATYPE) && !evtJson.defaultTracks) {
console.warn('unknown data type:', evtJson.higlassTrack);
return undefined;
}
const orientationToPositions = {
'1d-horizontal': ['top', 'bottom', 'left', 'right'],
'2d': ['center'],
'1d-vertical': ['left', 'right'],
};
const defaultTracks = DEFAULT_TRACKS_FOR_DATATYPE[datatype] || {};
if (evtJson.defaultTracks) {
for (const trackType of evtJson.defaultTracks) {
if (!TRACKS_INFO_BY_TYPE[trackType]) {
console.warn('unknown track type', trackType);
} else {
for (const position of orientationToPositions[
TRACKS_INFO_BY_TYPE[trackType].orientation
]) {
defaultTracks[position] = trackType;
}
}
}
}
const presentTracks = new Set(
['top', 'left', 'right', 'center', 'bottom'].filter(
(x) => x in this.state.tracks && this.state.tracks[x].length,
),
);
const topAllowed = 'top' in defaultTracks;
const leftAllowed = 'left' in defaultTracks;
const rightAllowed = 'right' in defaultTracks;
const bottomAllowed = 'bottom' in defaultTracks;
const centerAllowed = 'center' in defaultTracks;
const hasVerticalComponent =
'center' in defaultTracks ||
presentTracks.has('left') ||
presentTracks.has('right') ||
presentTracks.has('center');
const topDisplayed = 'top' in defaultTracks;
const bottomDisplayed = 'bottom' in defaultTracks && hasVerticalComponent;
const leftDisplayed = 'left' in defaultTracks && hasVerticalComponent;
const rightDisplayed = 'right' in defaultTracks && hasVerticalComponent;
const centerDisplayed =
Object.keys(defaultTracks).length &&
('center' in defaultTracks || hasVerticalComponent);
const topLeftDiv = (
<div
style={{
flexGrow: 1,
}}
/>
);
const topRightDiv = React.cloneElement(topLeftDiv);
const topDiv = (
<div
style={{
display: 'flex',
flexGrow: 1,
}}
>
{topDisplayed && (centerDispl