higlass
Version:
HiGlass Hi-C / genomic / large data viewer
1,730 lines (1,466 loc) • 91.2 kB
JSX
// @ts-nocheck
import clsx from 'clsx';
import { ElementQueries, ResizeSensor } from 'css-element-queries';
import { brush, brushX, brushY } from 'd3-brush';
import { format } from 'd3-format';
import { pointer, select } from 'd3-selection';
import PropTypes from 'prop-types';
import React from 'react';
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,
trackHeight,
trackWidth,
visitPositionedTracks,
} from './utils';
import getDefaultTracksForDataType from './utils/get-default-tracks-for-datatype';
// Configs
import {
MOUSE_TOOL_SELECT,
MOUSE_TOOL_TRACK_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
this.brushX = brushX();
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.annotationUid = null;
this.annotationCreatedNotified = false;
this.xScale = null;
this.yScale = null;
this.addUidsToTracks(tracks);
// Add names to all the tracks
this.trackToReplace = null;
this.trackRenderer = null;
this.brushSelection = 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,
addTrackExtent: null,
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.brushesCreated = {};
this.appZoomedBound = this.appZoomed.bind(this);
this.handleClickBound = this.handleClick.bind(this);
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 = this.divTiledPlot;
if (document.body.contains(thisElement)) {
callback();
} else {
requestAnimationFrame(() => this.waitForDOMAttachment(callback));
}
}
componentDidMount() {
this.mounted = true;
this.element = this.divTiledPlot;
// 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),
);
// this.pubSubs.push(
// this.props.pubSub.subscribe('click', evt => {
// if (this.brushEl) {
// const pos = pointer(evt, this.brushEl);
// console.log('click pos:', pos);
// console.log('this.brushEl', this.brushEl.node());
// }
// }),
// );
}
/** Get the data in the selection */
getTracksData(positionedTracks, extent) {
let tracks = [];
const allTrackObjs = this.listAllTrackObjects();
// get a list of viewconf track defs
for (const track of positionedTracks) {
if (track.track.contents) {
tracks = [...tracks, ...track.track.contents];
} else {
tracks = [...tracks, track.track];
}
}
const trackDatas = [];
// get data
for (const track of tracks) {
if (track.type === 'heatmap') {
const trackObj = allTrackObjs.filter((x) => x.id === track.uid)[0];
const x1 = trackObj._xScale(extent[0][0]);
const x2 = trackObj._xScale(extent[1][0]);
const y1 = trackObj._yScale(extent[0][1]);
const y2 = trackObj._yScale(extent[1][1]);
const height = y2 - y1;
const width = x2 - x1;
const data = trackObj.getVisibleRectangleData(x1, y1, height, width);
const sumValue = data.data.reduce((a, b) => a + b, 0);
const mean = sumValue / data.data.length;
trackDatas.push({
name: track.options.name,
mean,
trackUid: track.uid,
});
}
}
return trackDatas;
}
appZoomed() {
if (this.brushCurrent && this.brushEl) {
if (this.brushType === 'horizontal') {
const newSelection = [
this.xScale(this.brushSelection[0]) + this.brushTrack.left,
this.xScale(this.brushSelection[1]) + this.brushTrack.left,
];
this.brushEl.call(this.brushCurrent.move, newSelection);
}
if (this.brushType === 'vertical') {
const newSelection = [
this.yScale(this.brushSelection[0]) + this.brushTrack.top,
this.yScale(this.brushSelection[1]) + this.brushTrack.top,
];
this.brushEl.call(this.brushCurrent.move, newSelection);
}
if (this.brushType === '2d') {
const newSelection = [
[
this.xScale(this.brushSelection[0][0]) + this.brushTrack.left,
this.yScale(this.brushSelection[0][1]) + this.brushTrack.top,
],
[
this.xScale(this.brushSelection[1][0]) + this.brushTrack.left,
this.yScale(this.brushSelection[1][1]) + this.brushTrack.top,
],
];
this.brushEl.call(this.brushCurrent.move, newSelection);
}
}
}
clearOtherBrushes(myBrush) {
for (const aBrush of Object.values(this.brushes)) {
aBrush.on('brush', null);
}
for (const trackUid in this.brushes) {
const otherBrush = this.brushes[trackUid];
if (otherBrush !== myBrush) {
this.brushEls[trackUid].call(otherBrush.move, null);
// this.brushEls[trackUid].selectAll('.selection').remove();
}
}
for (const aBrush of Object.values(this.brushes)) {
aBrush.on('brush', () => this.clearOtherBrushes(aBrush));
}
}
handleClick(evt) {
const pos = pointer(evt, this.divTiledPlot);
let inside = false;
if (this.brushEl) {
const selection = this.brushSelectionRaw;
const track = this.brushTrack;
if (this.brushType === '2d') {
if (
pos[0] >= selection[0][0] &&
pos[0] <= selection[1][0] &&
pos[1] >= selection[0][1] &&
pos[1] <= selection[1][1]
) {
inside = true;
}
} else if (this.brushType === 'horizontal') {
if (
pos[0] >= selection[0] &&
pos[0] <= selection[1] &&
pos[1] >= track.top &&
pos[1] <= track.top + trackHeight(track.track)
) {
inside = true;
}
} else if (this.brushType === 'vertical') {
if (
pos[1] >= selection[0] &&
pos[1] <= selection[1] &&
pos[0] >= track.left &&
pos[0] <= track.left + trackWidth(track.track)
) {
inside = true;
}
}
if (!inside) {
this.cancelBrushes();
}
}
}
cancelBrushes() {
this.brushEl.call(this.brushCurrent.move, null);
this.brushEl = null;
this.props.apiPublish('annotationRemoved', this.annotationUid);
this.annotationUid = null;
this.annotationCreatedNotified = false;
this.removeBrushText();
}
removeBrushText() {
select(this.divTiledPlot)
.selectAll('.brush-svg')
.selectAll('.data-values')
.remove();
}
enableBrushes() {
const overlays = select(this.divTiledPlot)
.selectAll('.brush-svg')
.selectAll('.overlay');
overlays.style('pointer-events', 'all');
}
disableBrushes() {
select(this.divTiledPlot)
// .selectAll('.brush-svg')
.selectAll('.overlay')
.attr('pointer-events', 'none');
select(this.divTiledPlot)
.selectAll('.brush-rect')
.attr('pointer-events', 'none');
select(this.divTiledPlot)
.selectAll('.selection')
.attr('pointer-events', 'all');
select(this.divTiledPlot)
.selectAll('.brush-svg')
.attr('pointer-events', 'none');
}
createBrushes(positionedTracks) {
const brushes = {};
this.brushCurrent = null;
const apiPublish = this.props.apiPublish;
const tiledPlot = this;
for (const track of positionedTracks) {
if (brushes[track.track.uid]) continue;
let myBrush = null;
if (['top', 'bottom'].includes(track.track.position)) {
myBrush = brushX();
myBrush.on('brush', (event) => {
if (!event.selection) {
return;
}
this.brushCurrent = myBrush;
this.brushType = 'horizontal';
this.brushTrack = track;
this.brushSelectionRaw = event.selection;
this.brushSelection = [
this.xScale.invert(event.selection[0] - track.left),
this.xScale.invert(event.selection[1] - track.left),
];
apiPublish('annotationChanged', {
annotationUid: this.annotationUid,
viewUid: this.props.uid,
track: track.track,
extent: [this.brushSelection, [null, null]],
});
});
myBrush.on('end', (evt) => {
if (!this.annotationCreatedNotified) {
apiPublish('annotationCreated', {
annotationUid: tiledPlot.annotationUid,
track: track.track,
viewUid: this.props.uid,
extent: [this.brushSelection, [null, null]],
});
}
});
} else if (['left', 'right'].includes(track.track.position)) {
myBrush = brushY();
myBrush.on('brush', (event) => {
if (!event.selection) {
return;
}
this.brushCurrent = myBrush;
this.brushType = 'vertical';
this.brushTrack = track;
this.brushSelectionRaw = event.selection;
this.brushSelection = [
this.yScale.invert(event.selection[0] - track.top),
this.yScale.invert(event.selection[1] - track.top),
];
apiPublish('annotationChanged', {
annotationUid: this.annotationUid,
track: track.track,
viewUid: this.props.uid,
extent: [[null, null], this.brushSelection],
});
});
myBrush.on('end', (evt) => {
if (!this.annotationCreatedNotified) {
apiPublish('annotationCreated', {
annotationUid: tiledPlot.annotationUid,
track: track.track,
viewUid: this.props.uid,
extent: [[null, null], this.brushSelection],
});
}
});
} else {
myBrush = brush();
myBrush.on('brush', (event) => {
if (!event.selection) {
return;
}
this.brushCurrent = myBrush;
this.brushType = '2d';
this.brushTrack = track;
this.brushSelectionRaw = event.selection;
this.brushSelection = [
[
this.xScale.invert(event.selection[0][0] - track.left),
this.yScale.invert(event.selection[0][1] - track.top),
],
[
this.xScale.invert(event.selection[1][0] - track.left),
this.yScale.invert(event.selection[1][1] - track.top),
],
];
const tracksData = this.getTracksData(
positionedTracks.filter((t) => t.track.position === 'center'),
this.brushSelection,
);
const dataValues = Object.values(tracksData)
.filter((x) => x.mean !== undefined)
.map((x) => x.mean);
if (dataValues.length) {
const selection = select(this.divTiledPlot)
.selectAll('.brush-svg')
.selectAll('.data-values')
.data(dataValues);
selection.enter().append('text').classed('data-values', true);
const numFormat = format('.3f');
select(this.divTiledPlot)
.selectAll('.data-values')
.attr('x', event.selection[0][0])
.attr('y', event.selection[0][1])
.text((x) => `mean: ${numFormat(x)}`);
}
apiPublish('annotationChanged', {
annotationUid: this.annotationUid,
extent: this.brushSelection,
data: tracksData,
track: track.track,
viewUid: this.props.uid,
});
});
myBrush.on('end', (evt) => {
let tracksData = {};
if (this.brushSelection?.[0].length) {
tracksData = this.getTracksData(
positionedTracks.filter((t) => t.track.position === 'center'),
this.brushSelection,
);
}
if (!this.annotationCreatedNotified) {
apiPublish('annotationCreated', {
annotationUid: tiledPlot.annotationUid,
track: track.track,
viewUid: this.props.uid,
extent: this.brushSelection,
data: tracksData,
});
this.annotationCreatedNotified = true;
} else if (!evt.selection) {
this.removeBrushText();
}
});
}
// turn off d3-brush's control of the shift, meta, ctrl keys
myBrush.keyModifiers(false);
myBrush.extent([
[track.left, track.top],
[
track.left + trackWidth(track.track),
track.top + trackHeight(track.track),
],
]);
myBrush.on('start', function (event) {
if (!tiledPlot.annotationUid) {
tiledPlot.annotationUid = slugid.nice();
track.annotationUid = tiledPlot.annotationUid;
}
tiledPlot.brushEl = select(this);
});
brushes[track.track.uid] = myBrush;
}
return brushes;
}
removeBrushes() {
select(this.divTiledPlot).selectAll('.brush-svg').remove();
}
addBrushes() {
if (this.annotationUid) {
// we already have a selection, no need to create a new one
return;
}
// get all tracks and remove the ones that are in the
// "whole" position (e.g. rules)
const positionedTracks = this.positionedTracks().filter(
(x) => x.track.position !== 'whole',
);
const brushes = this.createBrushes(positionedTracks);
this.brushes = brushes;
const brushEls = {};
this.brushEls = brushEls;
select(this.divTiledPlot).selectAll('.brush').remove();
select(this.divTiledPlot)
.selectAll('.brush-svg')
.data([1])
.enter()
.append('svg')
.classed('brush-svg', true)
.style('position', 'absolute')
.style('left', 0)
.style('top', 0)
.style('z-index', 101);
const brushG = select(this.divTiledPlot)
.select('.brush-svg')
.selectAll('.brush')
.data(positionedTracks, (d) => d.track.uid)
.enter()
.append('g')
.attr('class', 'brush');
select(this.divTiledPlot)
.selectAll('.brush-svg')
.attr('width', this.state.width)
.attr('height', this.state.height);
select(this.divTiledPlot)
.selectAll('.brush-rect')
.attr('x', (d) => d.left)
.attr('y', (d) => d.top)
.style('stroke', '1px solid black')
.style('fill', 'transparent')
.attr('width', (d) => d.width)
.attr('height', (d) => d.height);
brushG.each(function (d) {
brushEls[d.track.uid] = select(this);
brushEls[d.track.uid].call(brushes[d.track.uid]);
});
// select(this.divTiledPlot).on('click', () => console.log('yyclick'));
// select(this.divTiledPlot)
// .select('.brush-svg')
// .selectAll('.brush');
// .attr('x', d => d.)
}
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 = {};
if (nextProps.mouseTool === MOUSE_TOOL_TRACK_SELECT) {
// this.enableBrushes();
this.addBrushes();
} else {
this.disableBrushes();
// this.removeBrushes();
}
return toUpdate;
}
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: We should consider refactoring
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
extent={this.state.addTrackExtent || this.props.addTrackExtent}
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);
}
handleCollapseTrack(trackUid) {
this.handleTrackOptionsChanged(trackUid, { collapsed: true });
}
handleExpandTrack(trackUid) {
this.handleTrackOptionsChanged(trackUid, { collapsed: false });
}
handleScalesChanged(x, y) {
this.xScale = x;
this.yScale = y;
this.appZoomed();
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;
}
if (tilesetInfo.row_infos) {
track.row_infos = tilesetInfo.row_infos;
}
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,
});
this.props.setOverTrackChooser(true);
}
handleOverlayMouseLeave(uid) {
if (uid === this.state.mouseOverOverlayUid) {
this.setState({
mouseOverOverlayUid: null,
});
}
this.props.setOverTrackChooser(false);
}
handleTrackPositionChosen(pTrack, evt) {
this.setState({ mouseOverOverlayUid: null });
this.props.chooseTrackHandler(pTrack.track.uid, evt);
}
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({
addTrackExtent: null,
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, extent) {
this.setState({
addTrackExtent: extent,
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, extent, 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, extent, host);
this.setState({
addTrackExtent: null,
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 = trackHeight(track);
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 += trackHeight(this.state.tracks.top[i]);
}
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 += trackHeight(this.state.tracks.bottom[i]);
}
break;
case 'left':
top += this.topHeight;
width = trackWidth(track);
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 += trackWidth(this.state.tracks.left[i]);
}
break;
case 'right':
left += this.leftWidth + this.centerWidth + this.galleryDim;
top += this.topHeight;
width = trackWidth(track);
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 += trackWidth(this.state.tracks.right[i]);
}
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;
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: trackWidth(trackPosition.track),
height: trackHeight(trackPosition.track),
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 tra