UNPKG

higlass

Version:

HiGlass Hi-C / genomic / large data viewer

1,730 lines (1,466 loc) 91.2 kB
// @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