UNPKG

higlass

Version:

HiGlass Hi-C / genomic / large data viewer

1,703 lines (1,452 loc) 77 kB
// @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