UNPKG

higlass

Version:

HiGlass Hi-C / genomic / large data viewer

353 lines (307 loc) 10.2 kB
import React from 'react'; import ReactDOM from 'react-dom'; import { mount } from 'enzyme'; import { requestsInFlight } from '../services'; import { getTrackObjectFromHGC, getTrackRenderer } from '../utils'; import HiGlassComponent from '../HiGlassComponent'; const TILE_LOADING_CHECK_INTERVAL = 100; /** * Change the options of a track in higlass * * @param {import("enzyme").ReactWrapper<{}, {}, HiGlassComponent>} hgc - Enzyme wrapper for a HiGlassComponent * @param {string} viewUid - The view uid * @param {string} trackUid - The track uid * @param {Record<string, unknown>} options - An object of new options (e.g. { color: 'black'}) * @returns {void} */ export const changeOptions = (hgc, viewUid, trackUid, options) => { for (const { viewId, trackId, track } of hgc.instance().iterateOverTracks()) { if (viewId === viewUid && trackId === trackUid) { track.options = { ...track.options, ...options, }; } } hgc.setState(hgc.instance().state); }; /** * Check if there are any active transitions that we need to wait on. * * @param {HiGlassComponent} hgc * * @returns {boolean} Whether any of the tracks have active transtions. */ export const areTransitionsActive = (hgc) => { for (const track of hgc.iterateOverTracks()) { const trackRenderer = getTrackRenderer(hgc, track.viewId); if (trackRenderer?.activeTransitions) return true; } return false; }; /** * Waits for multiple elements to appear in the DOM. * * @param {HTMLElement} parent - The parent element to search within. * @param {string[]} selectors - An array of CSS selectors for the elements to wait for. * @returns {Promise<Array<HTMLElement>>} */ const waitForElements = (parent, selectors) => { const foundElements = new Map(); /** @type {PromiseWithResolvers<Array<HTMLElement>>} */ const { promise, resolve } = Promise.withResolvers(); const observer = new MutationObserver((mutations, obs) => { selectors.forEach((selector) => { if (!foundElements.has(selector)) { const element = parent.querySelector(selector); if (element) { foundElements.set(selector, element); } } }); // If all elements are found, trigger the callback and disconnect if (foundElements.size === selectors.length) { resolve([...foundElements.values()]); // Pass all elements to the callback obs.disconnect(); } }); observer.observe(parent, { childList: true, subtree: true, }); // Initial check in case elements are already present selectors.forEach((selector) => { const element = parent.querySelector(selector); if (element) { foundElements.set(selector, element); } }); if (foundElements.size === selectors.length) { resolve([...foundElements.values()]); observer.disconnect(); } return promise; }; /** * Wait until all transitions have finished before calling the callback * * @param {HiGlassComponent} hgc * @param {() => void} callback A callback to invoke when all tiles have been loaded. * @returns {void} */ export const waitForTransitionsFinished = (hgc, callback) => { if (areTransitionsActive(hgc)) { setTimeout(() => { waitForTransitionsFinished(hgc, callback); }, TILE_LOADING_CHECK_INTERVAL); } else { callback(); } }; /** * Wait until all open JSON requests are finished * * @param {() => void} finished - A callback to invoke when there's no more JSON requests open. * @returns {void} */ export const waitForJsonComplete = (finished) => { if (requestsInFlight > 0) { setTimeout( () => waitForJsonComplete(finished), TILE_LOADING_CHECK_INTERVAL, ); } else { finished(); } }; /** * Check if a HiGlassComponent is still waiting on tiles from a remote server. * * @param {HiGlassComponent} hgc * @returns {boolean} Whether any of the tracks are wating for tiles. * */ export const isWaitingOnTiles = (hgc) => { for (const track of hgc.iterateOverTracks()) { let trackObj = getTrackObjectFromHGC(hgc, track.viewId, track.trackId); if ( !track.track.server && !track.track.tilesetUid && !(track.track.data && track.track.data.type === 'divided') && !(track.track.data && track.track.data.type === 'local-tiles') ) { continue; } if ( (track.track.data && track.track.data.type === 'divided') || (track.track.server && track.track.tilesetUid) || (track.track.data && track.track.data.type === 'local-tiles') ) { if (!trackObj) return true; if (trackObj.originalTrack) { trackObj = trackObj.originalTrack; } if (!(trackObj.tilesetInfo || trackObj.chromInfo)) { return true; } if (trackObj.fetching?.size) { return true; } } else { throw Error('"server" and "tilesetUid" belong together'); } } return false; }; /** * Wait until all of the tiles in the HiGlassComponent are loaded until calling the callback * * @param {HiGlassComponent} hgc * @param {(value?: unknown) => void} tilesLoadedCallback A callback to invoke whenever all of the tiles have been loaded. * @returns {void} */ export const waitForTilesLoaded = (hgc, tilesLoadedCallback) => { if (isWaitingOnTiles(hgc)) { setTimeout(() => { waitForTilesLoaded(hgc, tilesLoadedCallback); }, TILE_LOADING_CHECK_INTERVAL); } else { // console.log('finished'); tilesLoadedCallback(); } }; /** * Mount a new HiGlassComponent and unmount the previously visible one. * * @param {HTMLDivElement | null} prevDiv - A div element to detach and recreate for the component * @param {import("enzyme").ReactWrapper<{}, {}, HiGlassComponent> | null} prevHgc * @param {Record<string, unknown>} viewConf * @param {(value?: unknown) => void} done - The callback to call when the component is fully loaded * @param {{ style?: string, bounded?: boolean, extendedDelay?: boolean }} [options] */ export const mountHGComponent = ( prevDiv, prevHgc, viewConf, done, options = {}, ) => { const { style = 'width:800px; background-color: lightgreen;', bounded = false, extendedDelay = false, } = options; if (prevHgc) { prevHgc.unmount(); prevHgc.detach(); } if (prevDiv) { global.document.body.removeChild(prevDiv); } // console.log('check:', options && options.style) // console.log('style:', style, "options:", options, "style", options.style); const div = global.document.createElement('div'); global.document.body.appendChild(div); div.setAttribute('style', style); div.setAttribute('id', 'simple-hg-component'); /** @type {import("enzyme").ReactWrapper<{}, {}, HiGlassComponent>} */ const hgc = mount( <HiGlassComponent options={{ bounded }} viewConfig={viewConf} />, { attachTo: div }, ); hgc.update(); waitForJsonComplete(() => { if (extendedDelay) { // Waiting for tiles to be loaded does not always mean // that the compoment is mounted (especially if we load the tiles // from the filesystem, which is quick). Wait 1000ms to make sure // we are really done const doneWithDelay = () => setTimeout(() => { done(); }, 1000); waitForTilesLoaded(hgc.instance(), doneWithDelay); } else { waitForTilesLoaded(hgc.instance(), done); } }); return /** @type {const} */ ([div, hgc]); }; /** Wait for scales to stop changing. * * @param {HiGlassComponent} hgc * @param {string} viewUid * @param {Object} options * @param {number} [options.initialWait] - The interval (in milliseconds) that is waited before the first size check. * @param {number} [options.timeInterval] - The interval (in milliseconds) between size checks. * @param {number} [options.maxTime] - The maximum time (in milliseconds) to wait for stabilization. * @returns {Promise<void>} */ export const waitForScalesStabilized = async (hgc, viewUid, options) => { const { initialWait = 500, timeInterval = 100, maxTime = 3000 } = options; const xScaleDomain = [0, 0]; const yScaleDomain = [0, 0]; await new Promise((r) => setTimeout(r, initialWait)); for (let i = 0; i < maxTime; i += timeInterval) { const xScale = hgc.xScales[viewUid]; const yScale = hgc.yScales[viewUid]; if ( xScaleDomain[0] !== xScale.domain()[0] || xScaleDomain[1] !== xScale.domain()[1] || yScaleDomain[0] !== yScale.domain()[0] || yScaleDomain[1] !== yScale.domain()[1] ) { xScaleDomain[0] = xScale.domain()[0]; xScaleDomain[1] = xScale.domain()[1]; yScaleDomain[0] = yScale.domain()[0]; yScaleDomain[1] = yScale.domain()[1]; } else { return; } await new Promise((r) => setTimeout(r, timeInterval)); } }; /** * Wait for a HiGlassComponet to be ready at the given element. * * By ready we mean that a track-renderer-div is present and that its * size is not changing any more. * * @param {HTMLElement} div * @returns {Promise<void>} */ export const waitForComponentReady = async (div) => { await waitForElements(div, ['.track-renderer-div']); }; /** * @param {HTMLDivElement} div * @returns {void} */ export const removeHGComponent = (div) => { if (!div) return; ReactDOM.unmountComponentAtNode(div); document.body.removeChild(div); }; // ideally the "await-ers" avoid would be promises (rather than polling) // and that way `mountHGComponent` would be async by default. /** * @param {HTMLDivElement | null} prevDiv * @param {import("enzyme").ReactWrapper<{}, {}, HiGlassComponent> | null} prevHgc * @param {Record<string, unknown>} viewConf * @param {{ style?: string, bounded?: boolean, extendedDelay?: boolean }} [options] * @returns {Promise<[HTMLDivElement, { instance: () => HiGlassComponent }]>} */ export async function mountHGComponentAsync( prevDiv, prevHgc, viewConf, options, ) { /** @type {ReturnType<typeof mountHGComponent>}*/ let res; await new Promise((resolve) => { res = mountHGComponent(prevDiv, prevHgc, viewConf, resolve, options); }); // @ts-expect-error We know it's been resolved return res; }