UNPKG

higlass

Version:

HiGlass Hi-C / genomic / large data viewer

1,701 lines (1,432 loc) 166 kB
// @ts-nocheck import clsx from 'clsx'; import { ElementQueries, ResizeSensor } from 'css-element-queries'; import { scaleLinear } from 'd3-scale'; import { pointer, select } from 'd3-selection'; import * as PIXI from 'pixi.js'; import PropTypes from 'prop-types'; import createPubSub, { globalPubSub } from 'pub-sub-es'; import React from 'react'; import ReactGridLayout from 'react-grid-layout'; import slugid from 'slugid'; import parse from 'url-parse'; import vkbeautify from 'vkbeautify'; import ChromosomeInfo from './ChromosomeInfo'; import ExportLinkDialog from './ExportLinkDialog'; import GenomePositionSearchBox from './GenomePositionSearchBox'; import TiledPlot from './TiledPlot'; import ViewConfigEditor from './ViewConfigEditor'; import ViewHeader from './ViewHeader'; import createApi from './api'; import { all as icons } from './icons'; import createSymbolIcon from './symbol'; import { Provider as ModalProvider } from './hocs/with-modal'; // Higher-order components import { Provider as PubSubProvider } from './hocs/with-pub-sub'; import { Provider as ThemeProvider } from './hocs/with-theme'; import HiGlassComponentContext from './HiGlassComponentContext'; // Services import { chromInfo, createDomEvent, requestsInFlight, setTileProxyAuthHeader, tileProxy, } from './services'; // Utils import { debounce, dictFromTuples, dictItems, dictKeys, dictValues, download, fillInMinWidths, forwardEvent, getElementDim, getTrackByUid, getTrackObjById, getTrackPositionByUid, hasParent, // loadChromInfos, numericifyVersion, objVals, scalesCenterAndK, scalesToGenomeLoci, toVoid, visitPositionedTracks, } from './utils'; import positionedTracksToAllTracks from './utils/positioned-tracks-to-all-tracks'; // Configs import { DEFAULT_CONTAINER_PADDING_X, DEFAULT_CONTAINER_PADDING_Y, DEFAULT_SERVER, DEFAULT_VIEW_MARGIN, DEFAULT_VIEW_PADDING, GLOBALS, LOCATION_LISTENER_PREFIX, LONG_DRAG_TIMEOUT, MOUSE_TOOL_MOVE, MOUSE_TOOL_SELECT, MOUSE_TOOL_TRACK_SELECT, SHORT_DRAG_TIMEOUT, SIZE_MODE_BOUNDED, SIZE_MODE_BOUNDED_OVERFLOW, SIZE_MODE_DEFAULT, SIZE_MODE_OVERFLOW, SIZE_MODE_SCROLL, THEME_DARK, THEME_LIGHT, TRACKS_INFO_BY_TYPE, } from './configs'; // Styles import styles from '../styles/HiGlass.module.scss'; import stylesMTHeader from '../styles/ViewHeader.module.scss'; import '../styles/HiGlass.scss'; const NUM_GRID_COLUMNS = 12; const DEFAULT_NEW_VIEW_HEIGHT = 12; const VIEW_HEADER_HEIGHT = 20; class HiGlassComponent extends React.Component { constructor(props) { super(props); // Check React version if (numericifyVersion(React.version) < 15.6) { console.warn( 'HiGlass requires React v15.6 or higher. Current version: ', React.version, ); } this.topDivRef = React.createRef(); this.pubSub = createPubSub(); this.domEvent = createDomEvent(this.pubSub); this.pubSubs = []; this.minHorizontalHeight = 20; this.minVerticalWidth = 20; this.resizeSensor = null; this.uid = slugid.nice(); /** @type {Record<string, import('./TiledPlot').TiledPlot> */ this.tiledPlots = {}; this.genomePositionSearchBoxes = {}; // keep track of the xScales of each Track Renderer /** @type {Record<string, import('./TrackRenderer').Scale>} */ this.xScales = {}; /** @type {Record<string, import('./TrackRenderer').Scale>} */ this.yScales = {}; this.projectionXDomains = {}; this.projectionYDomains = {}; this.topDiv = null; this.zoomToDataExtentOnInit = new Set(); // a reference of view / track combinations // to be used with combined to viewAndTrackUid this.viewTrackUidsToCombinedUid = {}; this.combinedUidToViewTrack = {}; // event listeners for when the scales of a view change // bypasses the React event framework because this needs // to be fast // indexed by view uid and then listener uid this.scalesChangedListeners = {}; this.draggingChangedListeners = {}; this.valueScalesChangedListeners = {}; // locks that keep the location and zoom synchronized // between views this.zoomLocks = {}; this.locationLocks = {}; // axis-specific location lock this.locationLocksAxisWise = { x: {}, y: {} }; // locks that keep the value scales synchronized between // *tracks* (which can be in different views) this.valueScaleLocks = {}; this.prevAuthToken = props.options.authToken; this.setCenters = {}; this.plusImg = {}; this.configImg = {}; // allow a different PIXI to be passed in case the // caller wants to use a different version GLOBALS.PIXI = props.options?.PIXI || PIXI; this.viewMarginTop = +props.options.viewMarginTop >= 0 ? +props.options.viewMarginTop : DEFAULT_VIEW_MARGIN; this.viewMarginBottom = +props.options.viewMarginBottom >= 0 ? +props.options.viewMarginBottom : DEFAULT_VIEW_MARGIN; this.viewMarginLeft = +props.options.viewMarginLeft >= 0 ? +props.options.viewMarginLeft : DEFAULT_VIEW_MARGIN; this.viewMarginRight = +props.options.viewMarginRight >= 0 ? +props.options.viewMarginRight : DEFAULT_VIEW_MARGIN; this.viewPaddingTop = +props.options.viewPaddingTop >= 0 ? +props.options.viewPaddingTop : DEFAULT_VIEW_PADDING; this.viewPaddingBottom = +props.options.viewPaddingBottom >= 0 ? +props.options.viewPaddingBottom : DEFAULT_VIEW_PADDING; this.viewPaddingLeft = +props.options.viewPaddingLeft >= 0 ? +props.options.viewPaddingLeft : DEFAULT_VIEW_PADDING; this.viewPaddingRight = +props.options.viewPaddingRight >= 0 ? +props.options.viewPaddingRight : DEFAULT_VIEW_PADDING; this.genomePositionSearchBox = null; this.viewHeaders = {}; this.boundRefreshView = () => { this.refreshView(LONG_DRAG_TIMEOUT); }; this.unsetOnLocationChange = []; this.setTheme(props.options.theme, props.options.isDarkTheme); this.viewconfLoaded = false; const { viewConfig } = this.props; const views = this.processViewConfig( JSON.parse(JSON.stringify(this.props.viewConfig)), ); if (props.options.authToken) { setTileProxyAuthHeader(props.options.authToken); } this.pixiRoot = new GLOBALS.PIXI.Container(); this.pixiRoot.interactive = true; this.pixiStage = new GLOBALS.PIXI.Container(); this.pixiStage.interactive = true; this.pixiRoot.addChild(this.pixiStage); this.pixiMask = new GLOBALS.PIXI.Graphics(); this.pixiRoot.addChild(this.pixiMask); this.pixiStage.mask = this.pixiMask; this.element = null; this.scrollTop = 0; let mouseTool = MOUSE_TOOL_MOVE; if (this.props.options) { switch (this.props.options.mouseTool) { case MOUSE_TOOL_SELECT: mouseTool = MOUSE_TOOL_SELECT; break; case MOUSE_TOOL_TRACK_SELECT: mouseTool = MOUSE_TOOL_TRACK_SELECT; break; default: break; } } if (this.props.options.pluginTracks) { window.higlassTracksByType = Object.assign( window.higlassTracksByType || {}, this.props.options.pluginTracks, ); } const pluginTracks = {}; try { if (window.higlassTracksByType) { Object.entries(window.higlassTracksByType).forEach( ([trackType, trackDef]) => { pluginTracks[trackType] = trackDef; }, ); } } catch (e) { console.warn('Broken config of a plugin track'); } if (this.props.options.pluginDataFetchers) { window.higlassDataFetchersByType = Object.assign( window.higlassDataFetchersByType || {}, this.props.options.pluginDataFetchers, ); } const pluginDataFetchers = window.higlassDataFetchersByType; const rowHeight = this.props.options.pixelPreciseMarginPadding ? 1 : 30; this.mounted = false; this.pluginTracks = pluginTracks; this.pluginDataFetchers = pluginDataFetchers; this.state = { currentBreakpoint: 'lg', width: 0, height: 0, rowHeight, svgElement: null, canvasElement: null, customDialog: null, views, viewConfig, addTrackPositionMenuPosition: null, typedEditable: undefined, mouseOverOverlayUid: null, mouseTool, overTrackChooser: null, isDarkTheme: false, rangeSelection1dSize: [0, Number.POSITIVE_INFINITY], rangeSelectionToInt: false, modal: null, sizeMode: this.props.options?.sizeMode, }; // monitor whether this element is attached to the DOM so that // we can determine whether to add the resizesensor this.attachedToDOM = false; // Set up API const { public: api, destroy: apiDestroy, publish: apiPublish, stack: apiStack, } = createApi(this, this.pubSub); this.api = api; this.apiDestroy = apiDestroy; this.apiPublish = apiPublish; this.apiStack = apiStack; this.viewChangeListener = []; this.triggerViewChangeDb = debounce(this.triggerViewChange.bind(this), 250); this.pubSubs = []; this.rangeSelection = [null, null]; this.prevMouseHoverTrack = null; this.zooming = false; // Bound functions this.appClickHandlerBound = this.appClickHandler.bind(this); this.canvasClickHandlerBound = this.canvasClickHandler.bind(this); this.keyDownHandlerBound = this.keyDownHandler.bind(this); this.keyUpHandlerBound = this.keyUpHandler.bind(this); this.resizeHandlerBound = this.resizeHandler.bind(this); this.resizeHandlerBound = this.resizeHandler.bind(this); this.dispatchEventBound = this.dispatchEvent.bind(this); this.animateOnMouseMoveHandlerBound = this.animateOnMouseMoveHandler.bind(this); this.zoomStartHandlerBound = this.zoomStartHandler.bind(this); this.zoomEndHandlerBound = this.zoomEndHandler.bind(this); this.zoomHandlerBound = this.zoomHandler.bind(this); this.trackDroppedHandlerBound = this.trackDroppedHandler.bind(this); this.trackDimensionsModifiedHandlerBound = this.trackDimensionsModifiedHandler.bind(this); this.animateBound = this.animate.bind(this); this.animateOnGlobalEventBound = this.animateOnGlobalEvent.bind(this); this.requestReceivedHandlerBound = this.requestReceivedHandler.bind(this); this.wheelHandlerBound = this.wheelHandler.bind(this); this.mouseMoveHandlerBound = this.mouseMoveHandler.bind(this); this.onMouseLeaveHandlerBound = this.onMouseLeaveHandler.bind(this); this.onBlurHandlerBound = this.onBlurHandler.bind(this); this.openModalBound = this.openModal.bind(this); this.closeModalBound = this.closeModal.bind(this); this.handleEditViewConfigBound = this.handleEditViewConfig.bind(this); this.onScrollHandlerBound = this.onScrollHandler.bind(this); this.viewUidToNameBound = this.viewUidToName.bind(this); // for typed shortcuts (e.g. e-d-i-t) to toggle editable this.typedText = ''; this.typedTextTimeout = null; this.modal = { open: this.openModalBound, close: this.closeModalBound, }; this.setBroadcastMousePositionGlobally( this.props.options.broadcastMousePositionGlobally || this.props.options.globalMousePosition, ); this.setShowGlobalMousePosition( this.props.options.showGlobalMousePosition || this.props.options.globalMousePosition, ); } UNSAFE_componentWillMount() { this.domEvent.register('keydown', document); this.domEvent.register('keyup', document); this.domEvent.register('scroll', document); this.domEvent.register('resize', window); this.domEvent.register('orientationchange', window); this.domEvent.register('wheel', window); this.domEvent.register('mousedown', window, true); this.domEvent.register('mouseup', window, true); this.domEvent.register('click', window, true); this.domEvent.register('mousemove', window); this.domEvent.register('touchmove', window); this.domEvent.register('touchstart', window); this.domEvent.register('touchend', window); this.domEvent.register('touchcancel', window); this.domEvent.register('blur', window); this.pubSubs.push( this.pubSub.subscribe('app.click', this.appClickHandlerBound), this.pubSub.subscribe('blur', this.onBlurHandlerBound), this.pubSub.subscribe('keydown', this.keyDownHandlerBound), this.pubSub.subscribe('keyup', this.keyUpHandlerBound), this.pubSub.subscribe('resize', this.resizeHandlerBound), this.pubSub.subscribe('wheel', this.wheelHandlerBound), this.pubSub.subscribe('orientationchange', this.resizeHandlerBound), this.pubSub.subscribe('app.event', this.dispatchEventBound), this.pubSub.subscribe( 'app.animateOnMouseMove', this.animateOnMouseMoveHandlerBound, ), this.pubSub.subscribe('trackDropped', this.trackDroppedHandlerBound), this.pubSub.subscribe( 'trackDimensionsModified', this.trackDimensionsModifiedHandlerBound, ), this.pubSub.subscribe('app.zoomStart', this.zoomStartHandlerBound), this.pubSub.subscribe('app.zoomEnd', this.zoomEndHandlerBound), this.pubSub.subscribe('app.zoom', this.zoomHandlerBound), this.pubSub.subscribe( 'requestReceived', this.requestReceivedHandlerBound, ), ); if (this.props.getApi) { this.props.getApi(this.api); } } get sizeMode() { return typeof this.state.sizeMode === 'undefined' ? this.props.options.bounded ? 'bounded' : SIZE_MODE_DEFAULT : this.state.sizeMode; } setBroadcastMousePositionGlobally(isBroadcastMousePositionGlobally = false) { this.isBroadcastMousePositionGlobally = isBroadcastMousePositionGlobally; } setShowGlobalMousePosition(isShowGlobalMousePosition = false) { this.isShowGlobalMousePosition = isShowGlobalMousePosition; if (this.isShowGlobalMousePosition && !this.globalMousePositionListener) { this.globalMousePositionListener = globalPubSub.subscribe( 'higlass.mouseMove', this.animateOnGlobalEventBound, ); this.pubSubs.push(this.globalMousePositionListener); } if (this.isShowGlobalMousePosition && !this.globalMousePositionListener) { const index = this.pubSubs.findIndex( (listener) => listener === this.globalMousePositionListener, ); globalPubSub.unsubscribe(this.globalMousePositionListener); if (index >= 0) this.pubSubs.splice(index, 1); this.globalMousePositionListener = undefined; } } zoomStartHandler() { this.hideHoverMenu(); this.zooming = true; } zoomEndHandler() { this.zooming = false; } zoomHandler(evt) { if (!evt.sourceEvent) return; this.mouseMoveHandler(evt.sourceEvent); } waitForDOMAttachment(callback) { if (!this.mounted) return; const thisElement = this.topDivRef.current; if (thisElement && document.body.contains(thisElement)) { callback(); } else { requestAnimationFrame(() => this.waitForDOMAttachment(callback)); } } componentDidMount() { // the addEventListener is necessary because TrackRenderer determines where to paint // all the elements based on their bounding boxes. If the window isn't // in focus, everything is drawn at the top and overlaps. When it gains // focus we need to redraw everything in its proper place this.mounted = true; this.element = this.topDivRef.current; window.addEventListener('focus', this.boundRefreshView); Object.values(this.state.views).forEach((view) => { this.adjustLayoutToTrackSizes(view); if (!view.layout) { view.layout = this.generateViewLayout(view); } else { view.layout.i = view.uid; } }); const rendererOptions = { width: this.state.width, height: this.state.height, view: this.canvasElement, antialias: true, transparent: true, resolution: 2, autoResize: true, }; switch (PIXI.VERSION[0]) { case '4': console.warn( 'Deprecation warning: please update Pixi.js to version 5!', ); if (this.props.options.renderer === 'canvas') { this.pixiRenderer = new GLOBALS.PIXI.CanvasRenderer(rendererOptions); } else { this.pixiRenderer = new GLOBALS.PIXI.WebGLRenderer(rendererOptions); } break; // case '5': // case '6': // case '7': // Gosling uses PIXI.js v7 default: if (this.props.options.renderer === 'canvas') { this.pixiRenderer = new GLOBALS.PIXI.CanvasRenderer(rendererOptions); } else { this.pixiRenderer = new GLOBALS.PIXI.Renderer(rendererOptions); } break; } // PIXI.RESOLUTION=2; this.fitPixiToParentContainer(); // keep track of the width and height of this element, because it // needs to be reflected in the size of our drawing surface this.setState({ svgElement: this.svgElement, canvasElement: this.canvasElement, }); this.waitForDOMAttachment(() => { ElementQueries.listen(); this.resizeSensor = new ResizeSensor( this.element.parentNode, this.updateAfterResize.bind(this), ); // this.forceUpdate(); this.updateAfterResize(); }); this.handleDragStart(); this.handleDragStop(); this.animate(); // this.handleExportViewsAsLink(); const baseSvg = select(this.element).append('svg').style('display', 'none'); // Add SVG Icons icons.forEach((icon) => createSymbolIcon(baseSvg, icon.id, icon.paths, icon.viewBox), ); } getTrackObject(viewUid, trackUid) { return this.tiledPlots[viewUid].trackRenderer.getTrackObject(trackUid); } getTrackRenderer(viewUid) { return this.tiledPlots[viewUid].trackRenderer; } UNSAFE_componentWillReceiveProps(newProps) { if (this.mounted) { this.setState({ viewConfig: newProps.viewConfig }); } const viewsByUid = this.processViewConfig( JSON.parse(JSON.stringify(newProps.viewConfig)), ); if (newProps.options.authToken !== this.prevAuthToken) { // we go a new auth token so we should reload everything setTileProxyAuthHeader(newProps.options.authToken); this.reload(); this.prevAuthToken = newProps.options.authToken; } // make sure that the current view is tall enough to display // all the tracks (if unbounded, which is checked in adjustLayout...) for (const view of dictValues(viewsByUid)) { this.adjustLayoutToTrackSizes(view); } this.setState({ views: viewsByUid, }); } UNSAFE_componentWillUpdate() { // let width = this.element.clientWidth; // let height = this.element.clientHeight; this.pixiRenderer.render(this.pixiRoot); } reload() { for (const viewId of this.iterateOverViews()) { const trackRenderer = this.getTrackRenderer(viewId); if (!trackRenderer) continue; const trackDefinitions = JSON.parse(trackRenderer.prevTrackDefinitions); // this will remove all the tracks and then recreate them // re-requesting all tiles with the new auth key trackRenderer.syncTrackObjects([]); trackRenderer.syncTrackObjects(trackDefinitions); } } componentDidUpdate() { this.setTheme(this.props.options.theme, this.props.options.isDarkTheme); this.animate(); this.triggerViewChangeDb(); } componentWillUnmount() { // Destroy PIXI renderer, stages, and assets this.mounted = false; this.pixiStage.destroy(false); this.pixiStage = null; this.pixiRenderer.destroy(true); this.pixiRenderer = null; window.removeEventListener('focus', this.boundRefreshView); // if this element was never attached to the DOM // then the resize sensor will never have been initiated if (this.resizeSensor) this.resizeSensor.detach(); this.domEvent.unregister('keydown', document); this.domEvent.unregister('keyup', document); this.domEvent.unregister('scroll', document); this.domEvent.unregister('wheel', window); this.domEvent.unregister('mousedown', window); this.domEvent.unregister('mouseup', window); this.domEvent.unregister('click', window); this.domEvent.unregister('mousemove', window); this.domEvent.unregister('touchmove', window); this.domEvent.unregister('touchstart', window); this.domEvent.unregister('touchend', window); this.domEvent.unregister('touchcancel', window); this.pubSubs.forEach((subscription) => this.pubSub.unsubscribe(subscription), ); this.pubSubs = []; this.apiDestroy(); } /* ---------------------------- Custom Methods ---------------------------- */ setTheme( newTheme = this.props.options.theme, isDarkTheme = this.props.options.isDarkTheme, ) { if (typeof isDarkTheme !== 'undefined') { console.warn( 'The option `isDarkTheme` is deprecated. Please use `theme` instead.', ); this.theme = isDarkTheme ? 'dark' : 'light'; } else { switch (newTheme) { case 'dark': this.theme = THEME_DARK; break; case 'light': case undefined: this.theme = THEME_LIGHT; break; default: console.warn(`Unknown theme "${newTheme}". Using light theme.`); this.theme = THEME_LIGHT; break; } } } dispatchEvent(e) { if (!this.canvasElement) return; forwardEvent(e, this.canvasElement); } trackDroppedHandler() { this.setState({ draggingHappening: null, }); } requestReceivedHandler() { if (!this.viewconfLoaded && requestsInFlight === 0) { this.viewconfLoaded = true; if (this.props.options.onViewConfLoaded) { this.props.options.onViewConfLoaded(); } } } animateOnMouseMoveHandler(active) { if (active && !this.animateOnMouseMove) { this.pubSubs.push( this.pubSub.subscribe('app.mouseMove', this.animateBound), ); } this.animateOnMouseMove = active; } fitPixiToParentContainer() { const element = this.topDivRef.current; if (!element || !element.parentNode) { // console.warn('No parentNode:', element); return; } const width = element.parentNode.clientWidth; const height = element.parentNode.clientHeight; this.pixiMask.beginFill(0xffffff).drawRect(0, 0, width, height).endFill(); this.pixiRenderer.resize(width, height); this.pixiRenderer.view.style.width = `${width}px`; this.pixiRenderer.view.style.height = `${height}px`; this.pixiRenderer.render(this.pixiRoot); } /** * Add default track options. These can come from two places: * * 1. The track definitions (configs/tracks-info.js) * 2. The default options passed into the component * * Of these, #2 takes precendence over #1. * * @param {array} track The track to add default options to */ addDefaultTrackOptions(track) { const trackInfo = this.getTrackInfo(track.type); if (!trackInfo) return; if (typeof track.options === 'undefined') { track.options = {}; } const trackOptions = track.options ? track.options : {}; if (this.props.options.defaultTrackOptions) { if (this.props.options.defaultTrackOptions.trackSpecific?.[track.type]) { // track specific options take precedence over all options const options = this.props.options.defaultTrackOptions.trackSpecific[track.type]; for (const optionName in options) { track.options[optionName] = typeof track.options[optionName] !== 'undefined' ? track.options[optionName] : JSON.parse(JSON.stringify(options[optionName])); } } if (this.props.options.defaultTrackOptions.all) { const options = this.props.options.defaultTrackOptions.all; for (const optionName in options) { track.options[optionName] = typeof track.options[optionName] !== 'undefined' ? track.options[optionName] : JSON.parse(JSON.stringify(options[optionName])); } } } if (trackInfo.defaultOptions) { const defaultThemeOptions = trackInfo.defaultOptionsByTheme?.[this.theme] ? trackInfo.defaultOptionsByTheme[this.theme] : {}; const defaultOptions = { ...trackInfo.defaultOptions, ...defaultThemeOptions, }; if (!track.options) { track.options = JSON.parse(JSON.stringify(defaultOptions)); } else { for (const optionName in defaultOptions) { track.options[optionName] = typeof track.options[optionName] !== 'undefined' ? track.options[optionName] : JSON.parse(JSON.stringify(defaultOptions[optionName])); } } } else { track.options = trackOptions; } } toggleTypedEditable() { this.setState({ typedEditable: !this.isEditable(), }); } /** Handle typed commands (e.g. e-d-i-t) */ typedTextHandler(event) { if (!this.props.options.cheatCodesEnabled) { return; } this.typedText = this.typedText.concat(event.key); if (this.typedText.endsWith('hgedit')) { this.toggleTypedEditable(); this.typedText = ''; } // 1.5 seconds to type the next letter const TYPED_TEXT_TIMEOUT = 750; if (this.typedTextTimeout) { clearTimeout(this.typedTextTimeout); } // set a timeout for new typed text this.typedTextTimeout = setTimeout(() => { this.typedText = ''; }, TYPED_TEXT_TIMEOUT); } keyDownHandler(event) { // handle typed commands (e.g. e-d-i-t) this.typedTextHandler(event); if (this.props.options.rangeSelectionOnAlt && event.key === 'Alt') { this.setState({ mouseTool: MOUSE_TOOL_SELECT, }); } } keyUpHandler(event) { if (this.props.options.rangeSelectionOnAlt && event.key === 'Alt') { this.setState({ mouseTool: MOUSE_TOOL_MOVE, }); } } openModal(modal) { this.setState({ // The following is only needed for testing purposes modal: React.cloneElement(modal, { ref: (c) => { this.modalRef = c; }, }), }); } closeModal() { this.modalRef = null; this.setState({ modal: null }); } handleEditViewConfig() { const { viewConfig: viewConfigTmp } = this.state; this.setState({ viewConfigTmp }); this.openModal( <ViewConfigEditor onCancel={() => { const { viewConfigTmp: viewConfig } = this.state; const views = this.processViewConfig(viewConfig); for (const view of dictValues(views)) { this.adjustLayoutToTrackSizes(view); } this.setState({ views, viewConfig, viewConfigTmp: null, }); }} onChange={(viewConfigJson) => { const viewConfig = JSON.parse(viewConfigJson); const views = this.processViewConfig(viewConfig); for (const view of dictValues(views)) { this.adjustLayoutToTrackSizes(view); } this.setState({ views, viewConfig }); }} onSave={(viewConfigJson) => { const viewConfig = JSON.parse(viewConfigJson); const views = this.processViewConfig(viewConfig); for (const view of dictValues(views)) { this.adjustLayoutToTrackSizes(view); } this.setState({ views, viewConfig, viewConfigTmp: null, }); }} viewConfig={this.getViewsAsString()} />, ); } animate() { if (this.isRequestingAnimationFrame) return; this.isRequestingAnimationFrame = true; requestAnimationFrame(() => { // component was probably unmounted if (!this.pixiRenderer) return; this.pixiRenderer.render(this.pixiRoot); this.isRequestingAnimationFrame = false; }); } animateOnGlobalEvent({ sourceUid } = {}) { if (sourceUid !== this.uid && this.animateOnMouseMove) this.animate(); } measureSize() { const [width, height] = getElementDim(this.element); if (width > 0 && height > 0) { this.setState({ sizeMeasured: true, width, height, }); } } updateAfterResize() { this.measureSize(); this.updateRowHeight(); this.fitPixiToParentContainer(); this.refreshView(LONG_DRAG_TIMEOUT); this.resizeHandler(); } onBreakpointChange(breakpoint) { this.setState({ currentBreakpoint: breakpoint, }); } handleOverlayMouseEnter(uid) { this.setState({ mouseOverOverlayUid: uid, }); } handleOverlayMouseLeave() { this.setState({ mouseOverOverlayUid: null, }); } /** * We want to lock the zoom of this view to the zoom of another view. * * First we pick which other view we want to lock to. * * The we calculate the current zoom offset and center offset. The differences * between the center of the two views will always remain the same, as will the * different between the zoom levels. */ handleLockLocation(uid) { // create a view chooser and remove the config view menu this.setState({ chooseViewHandler: (uid2) => this.handleLocationLockChosen(uid, uid2), mouseOverOverlayUid: uid, }); } /** * Can views be added, removed or rearranged and are the view headers * visible? */ isEditable() { if (this.state.typedEditable !== undefined) { // somebody typed "edit" so we need to follow the directive of // this cheat code over all other preferences return this.state.typedEditable; } if (!this.props.options || !('editable' in this.props.options)) { return this.state.viewConfig.editable; } return this.props.options.editable && this.state.viewConfig.editable; } /** * Can views be added, removed or rearranged and are the view headers * visible? */ isTrackMenuDisabled() { if ( this.props.options && (this.props.options.editable === false || this.props.options.tracksEditable === false) ) { return true; } return ( this.state.viewConfig && (this.state.viewConfig.tracksEditable === false || this.state.viewConfig.editable === false) ); } /** * Can views be added, removed or rearranged and are the view headers * visible? */ isViewHeaderDisabled() { if ( this.props.options && (this.props.options.editable === false || this.props.options.viewEditable === false) ) { return true; } return ( this.state.viewConfig && (this.state.viewConfig.viewEditable === false || this.state.viewConfig.editable === false) ); } /** * Iteratate over all of the views in this component */ iterateOverViews() { const viewIds = []; for (const viewId of Object.keys(this.state.views)) { viewIds.push(viewId); } return viewIds; } iterateOverTracksInView(viewId) { const allTracks = []; const { tracks } = this.state.views[viewId]; for (const trackType in tracks) { for (const track of tracks[trackType]) { if (track.type === 'combined' && track.contents) { for (const subTrack of track.contents) { allTracks.push({ viewId, trackId: subTrack.uid, track: subTrack }); } } else { allTracks.push({ viewId, trackId: track.uid, track }); } } } return allTracks; } /** * Iterate over all the tracks in this component. */ iterateOverTracks() { /** @type {Array<{ viewId: string, trackId: string, track: import('./types').UnknownTrackConfig }>}*/ const allTracks = []; for (const viewId in this.state.views) { const { tracks } = this.state.views[viewId]; for (const trackType in tracks) { for (const track of tracks[trackType]) { if (track.type === 'combined' && track.contents) { for (const subTrack of track.contents) { allTracks.push({ viewId, trackId: subTrack.uid, track: subTrack, }); } } else { allTracks.push({ viewId, trackId: track.uid, track }); } } } } return allTracks; } setMouseTool(mouseTool) { this.setState({ mouseTool }); } setSizeMode(sizeMode) { this.setState({ sizeMode }); } /** * Checks if a track's value scale is locked with another track */ isValueScaleLocked(viewUid, trackUid) { const uid = this.combineViewAndTrackUid(viewUid, trackUid); // the view must have been deleted if (!this.state.views[viewUid]) { return false; } if (this.valueScaleLocks[uid]) { return true; } return false; } /** * Computed the minimal and maximal values of all tracks that are in the same * lockGroup as a given track * @param {string} viewUid The id of the view containing the track * @param {string} trackUid The id of the track * @return {array} Tuple [min,max] containing the overall extrema - or null. */ getLockGroupExtrema(viewUid, trackUid) { const uid = this.combineViewAndTrackUid(viewUid, trackUid); // the view must have been deleted if (!this.state.views[viewUid]) { return null; } if (!this.valueScaleLocks[uid]) { return null; } const lockGroup = this.valueScaleLocks[uid]; const lockedTracks = Object.values(lockGroup) .filter((track) => this.tiledPlots[track.view]) .map((track) => this.tiledPlots[track.view].trackRenderer.getTrackObject(track.track), ) // filter out stale locks with non-existant tracks .filter((track) => track) // Filter out tracks that don't have values scales (e.g. chromosome tracks). // The .originalTrack check covers LeftTrackModifier style tracks. .filter((track) => track.valueScale || track.originalTrack?.valueScale) // if the track is a LeftTrackModifier we want the originalTrack .map((track) => track.originalTrack === undefined ? track : track.originalTrack, ); const minValues = lockedTracks // exclude tracks that don't set min and max values .filter((track) => track.minRawValue && track.maxRawValue) .map((track) => lockGroup.ignoreOffScreenValues ? track.minVisibleValue(true) : track.minVisibleValueInTiles(true), ); const maxValues = lockedTracks // exclude tracks that don't set min and max values .filter((track) => track.minRawValue && track.maxRawValue) .map((track) => lockGroup.ignoreOffScreenValues ? track.maxVisibleValue(true) : track.maxVisibleValueInTiles(true), ); if ( minValues.length === 0 || minValues.filter((x) => x === null || x === Number.POSITIVE_INFINITY) .length > 0 ) { return null; // Data hasn't loaded completely } if ( maxValues.length === 0 || maxValues.filter((x) => x === null || x === Number.NEGATIVE_INFINITY) .length > 0 ) { return null; // Data hasn't loaded completely } const allMin = Math.min(...minValues); const allMax = Math.max(...maxValues); return [allMin, allMax]; } /** * Syncing the values of locked scales * * Arguments * --------- * viewUid: string * The id of the view containing the track whose value scale initially changed * trackUid: string * The id of the track that whose value scale changed * * Returns * ------- * Nothing */ syncValueScales(viewUid, trackUid) { const uid = this.combineViewAndTrackUid(viewUid, trackUid); if (!this.state.views[viewUid]) return; // the view must have been deleted const sourceTrack = getTrackByUid( this.state.views[viewUid].tracks, trackUid, ); if (this.valueScaleLocks[uid]) { const lockGroup = this.valueScaleLocks[uid]; const lockedTracks = Object.values(lockGroup) .filter((track) => this.tiledPlots[track.view]) .map((track) => this.tiledPlots[track.view].trackRenderer.getTrackObject(track.track), ) // filter out locks with non-existant tracks .filter((track) => track) // if the track is a LeftTrackModifier we want the originalTrack .map((track) => track.originalTrack === undefined ? track : track.originalTrack, ); const lockGroupExtrema = this.getLockGroupExtrema(viewUid, trackUid); if (lockGroupExtrema === null) { return; // Data hasn't loaded completely } const allMin = lockGroupExtrema[0]; const allMax = lockGroupExtrema[1]; const epsilon = 1e-6; for (const lockedTrack of lockedTracks) { // set the newly calculated minimum and maximum values // using d3 style setters if (lockedTrack.minValue) { lockedTrack.minValue(allMin); } if (lockedTrack.maxValue) { lockedTrack.maxValue(allMax); } if (!lockedTrack.valueScale) { // this track probably hasn't loaded the tiles to // create a valueScale continue; } const hasScaleChanged = Math.abs( lockedTrack.minValue() - lockedTrack.valueScale.domain()[0], ) > epsilon || Math.abs( lockedTrack.maxValue() - lockedTrack.valueScale.domain()[1], ) > epsilon; const hasBrushMoved = sourceTrack.options && lockedTrack.options && typeof sourceTrack.options.scaleStartPercent !== 'undefined' && typeof sourceTrack.options.scaleEndPercent !== 'undefined' && (Math.abs( lockedTrack.options.scaleStartPercent - sourceTrack.options.scaleStartPercent, ) > epsilon || Math.abs( lockedTrack.options.scaleEndPercent - sourceTrack.options.scaleEndPercent, ) > epsilon); // If we do view based scaling we want to minimize the number of rerenders // Check if it is necessary to rerender if ( lockedTrack.continuousScaling && !hasScaleChanged && !hasBrushMoved ) { continue; } lockedTrack.valueScale.domain([allMin, allMax]); // In TiledPixiTrack, we check if valueScale has changed before // calling onValueScaleChanged. If we don't update prevValueScale // here, that function won't get called and the value scales won't // stay synced lockedTrack.prevValueScale = lockedTrack.valueScale.copy(); if (hasBrushMoved) { lockedTrack.options.scaleStartPercent = sourceTrack.options.scaleStartPercent; lockedTrack.options.scaleEndPercent = sourceTrack.options.scaleEndPercent; } // the second parameter forces a rerender even though // the options haven't changed lockedTrack.rerender(lockedTrack.options, true); } } } handleNewTilesLoaded(viewUid, trackUid) { // this.syncValueScales(viewUid, trackUid); this.animate(); } notifyDragChangedListeners(dragging) { // iterate over viewId dictValues(this.draggingChangedListeners).forEach((l) => { // iterate over listenerId dictValues(l).forEach((listener) => listener(dragging)); }); } /** * Add a listener that will be called every time the view is updated. * * @param viewUid: The uid of the view being observed * @param listenerUid: The uid of the listener * @param eventHandler: The handler to be called when the scales change * Event handler is called with parameters (xScale, yScale) */ addDraggingChangedListener(viewUid, listenerUid, eventHandler) { if (!(viewUid in this.draggingChangedListeners)) { this.draggingChangedListeners[viewUid] = {}; } this.draggingChangedListeners[viewUid][listenerUid] = eventHandler; eventHandler(true); eventHandler(false); } /** * Remove a scale change event listener * * @param viewUid: The view that it's listening on. * @param listenerUid: The uid of the listener itself. */ removeDraggingChangedListener(viewUid, listenerUid) { if (viewUid in this.draggingChangedListeners) { const listeners = this.draggingChangedListeners[viewUid]; if (listenerUid in listeners) { // make sure the listener doesn't think we're still // dragging listeners[listenerUid](false); delete listeners[listenerUid]; } } } /** * Add an event listener that will be called every time the scale * of the view with uid viewUid is changed. * * @param viewUid: The uid of the view being observed * @param listenerUid: The uid of the listener * @param eventHandler: The handler to be called when the scales change * Event handler is called with parameters (xScale, yScale) */ addScalesChangedListener(viewUid, listenerUid, eventHandler) { if (!this.scalesChangedListeners[viewUid]) { this.scalesChangedListeners[viewUid] = {}; } this.scalesChangedListeners[viewUid][listenerUid] = eventHandler; if (!this.xScales[viewUid] || !this.yScales[viewUid]) { return; } // call the handler for the first time eventHandler(this.xScales[viewUid], this.yScales[viewUid]); } /** * Remove a scale change event listener * * @param viewUid: The view that it's listening on. * @param listenerUid: The uid of the listener itself. */ removeScalesChangedListener(viewUid, listenerUid) { if (this.scalesChangedListeners[viewUid]) { const listeners = this.scalesChangedListeners[viewUid]; if (listeners[listenerUid]) { delete listeners[listenerUid]; } } } createSVG() { const svg = document.createElement('svg'); svg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); svg.setAttribute('version', '1.1'); for (const tiledPlot of dictValues(this.tiledPlots)) { if (!tiledPlot) continue; // probably opened and closed for (const trackDefObject of dictValues( tiledPlot.trackRenderer.trackDefObjects, )) { if (trackDefObject.trackObject.exportSVG) { const trackSVG = trackDefObject.trackObject.exportSVG(); if (trackSVG) svg.appendChild(trackSVG[0]); } } } // FF is fussier than Chrome, and requires dimensions on the SVG, // if it is to be used as an image src. svg.setAttribute('width', this.canvasElement.style.width); svg.setAttribute('height', this.canvasElement.style.height); if (this.postCreateSVGCallback) { // Allow the callback function to modify the exported SVG string // before it is finalized and returned. const modifiedSvg = this.postCreateSVGCallback(svg); return modifiedSvg; } return svg; } createSVGString() { const svg = this.createSVG(); let svgString = vkbeautify.xml( new window.XMLSerializer().serializeToString(svg), ); svgString = svgString.replace(/<a0:/g, '<'); svgString = svgString.replace(/<\/a0:/g, '</'); // Remove duplicated xhtml namespace property svgString = svgString.replace( /(<svg[\n\r])(\s+xmlns="http:\/\/www\.w3\.org\/1999\/xhtml"[\n\r])/gm, '$1', ); // Remove duplicated svg namespace svgString = svgString.replace( /(\s+<clipPath[\n\r]\s+)(xmlns="http:\/\/www\.w3\.org\/2000\/svg")/gm, '$1', ); const xmlDeclaration = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'; const doctype = '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">'; return `${xmlDeclaration}\n${doctype}\n${svgString}`; } handleExportSVG() { download( 'export.svg', new Blob([this.createSVGString()], { type: 'image/svg+xml' }), ); } offPostCreateSVG() { this.postCreateSVGCallback = null; } onPostCreateSVG(callback) { this.postCreateSVGCallback = callback; } createPNGBlobPromise() { return new Promise((resolve) => { // It would seem easier to call canvas.toDataURL()... // Except that with webgl context, it swaps buffers after drawing // and you don't have direct access to what is on-screen. // (You end up getting a PNG of the desired dimensions, but it is empty.) // // We'd either need to // - Turn on preserveDrawingBuffer and rerender, and add a callback // - Or leave it off, and somehow synchronously export before the swap // - Or look into low-level stuff like copyBufferSubData. // // Basing it on the SVG also guarantees us that the two exports are the same. const svgString = this.createSVGString(); const img = new Image( this.canvasElement.width, this.canvasElement.height, ); img.src = `data:image/svg+xml;base64,${btoa( unescape(encodeURIComponent(svgString)), )}`; img.onload = () => { const targetCanvas = document.createElement('canvas'); // TODO: I have no idea why dimensions are doubled! targetCanvas.width = this.canvasElement.width / 2; targetCanvas.height = this.canvasElement.height / 2; targetCanvas.getContext('2d').drawImage(img, 0, 0); targetCanvas.toBlob((blob) => { resolve(blob); }); }; }); } handleExportPNG() { this.createPNGBlobPromise().then((blob) => { download('export.png', blob); }); } /* * The scales of some view have changed (presumably in response to zooming). * * Mark the new scales and update any locked views. * * @param uid: The view of whom the scales have changed. */ handleScalesChanged(uid, xScale, yScale, notify = true) { this.xScales[uid] = xScale; this.yScales[uid] = yScale; if (notify) { if (uid in this.scalesChangedListeners) { dictValues(this.scalesChangedListeners[uid]).forEach((x) => { x(xScale, yScale); }); } } if (this.zoomLocks[uid]) { // this view is locked to another const lockGroup = this.zoomLocks[uid]; const lockGroupItems = dictItems(lockGroup); const [centerX, centerY, k] = scalesCenterAndK( this.xScales[uid], this.yScales[uid], ); for (let i = 0; i < lockGroupItems.length; i++) { const key = lockGroupItems[i][0]; const value = lockGroupItems[i][1]; if (!this.xScales[key] || !this.yScales[key]) { continue; } if (key === uid) { // no need to notify oneself that the scales have changed continue; } const [keyCenterX, keyCenterY, keyK] = scalesCenterAndK( this.xScales[key], this.yScales[key], ); const rk = value[2] / lockGroup[uid][2]; // let newCenterX = centerX + dx; // let newCenterY = centerY + dy; const newK = k * rk; if (!this.setCenters[key]) { continue; } // the key here is the target of zoom lock, so we want to keep its // x center and y center unchanged const [newXScale, newYScale] = this.setCenters[key]( keyCenterX, keyCenterY, newK, false, ); // because the setCenters call above has a 'false' notify, the new scales won't // be propagated from there, so we have to store them here this.xScales[key] = newXScale; this.yScales[key] = newYScale; // notify the listeners of all locked views that the scales of // this view have changed if (key in this.scalesChangedListeners) { dictValues(this.scalesChangedListeners[key]).forEach((x) => { x(newXScale, newYScale); }); } } } if (this.locationLocks[uid]) { // this view is locked to another const lockGroup = this.locationLocks[uid]; const lockGroupItems = dictItems(lockGroup); const [centerX, centerY, k] = scalesCenterAndK( this.xScales[uid], this.yScales[uid], ); for (let i = 0; i < lockGroupItems.length; i++) { const key = lockGroupItems[i][0]; const value = lockGroupItems[i][1]; if (!this.xScales[key] || !this.yScales[key]) { continue; } const [keyCenterX, keyCenterY, keyK] = scalesCenterAndK( this.xScales[key], this.yScales[key], ); if (key === uid) { // no need to notify oneself that the scales have changed continue; } const dx = value[0] - lockGroup[uid][0]; const dy = value[1] - lockGroup[uid][1]; const newCenterX = centerX + dx; const newCenterY = centerY + dy; if (!this.setCenters[key]) { continue; } const [newXScale, newYScale] = this.setCenters[key]( newCenterX, newCenterY, keyK, false, ); // because the setCenters call above has a 'false' notify, the new scal