UNPKG

higlass

Version:

HiGlass Hi-C / genomic / large data viewer

1,120 lines (921 loc) 33.7 kB
import { median, range, ticks } from 'd3-array'; import { scaleLinear, scaleLog, scaleQuantile } from 'd3-scale'; import slugid from 'slugid'; import PixiTrack from './PixiTrack'; import { DataFetcher } from './data-fetchers'; import backgroundTaskScheduler from './utils/background-task-scheduler'; // Utils import parseChromsizesRows from './utils/parse-chromsizes-rows.js'; import throttleAndDebounce from './utils/throttle-and-debounce.js'; import { isResolutionsTilesetInfo, isTilesetInfo } from './utils/type-guards'; // Configs import GLOBALS from './configs/globals'; import { ZOOM_DEBOUNCE } from './configs/primitives'; /** @import * as t from './types' */ /** * Get a valueScale for a heatmap. * * If the scalingType isn't specified, then default to the defaultScaling. * * @param {string} scalingType: The type of the (e.g. 'linear', or 'log') * @param {number} minValue: The minimum data value to which this scale will apply * @param {number} pseudocountIn: A value to add to all numbers to prevent taking the log of 0 * @param {number} maxValue: The maximum data value to which this scale will apply * @param {string} defaultScaling: The default scaling type to use in case * 'scalingType' is null (e.g. 'linear' or 'log') * * @returns {[string, import('d3-scale').ScaleLogarithmic<number, number> | import('d3-scale').ScaleLinear<number, number>]} * An array of [string, scale] containing the scale type and a scale with an appropriately set domain and range */ export function getValueScale( scalingType, minValue, pseudocountIn, maxValue, defaultScaling, ) { const scalingTypeToUse = scalingType || defaultScaling; // purposely set to not equal pseudocountIn for now // eventually this will be an option const pseudocount = 0; if (scalingTypeToUse === 'log' && minValue > 0) { return [ 'log', scaleLog() .range([254, 0]) .domain([minValue + pseudocount, maxValue + pseudocount]), ]; } if (scalingTypeToUse === 'log') { // warn the users that their desired scaling type couldn't be used // console.warn('Negative values present in data. Defaulting to linear scale: ', minValue); } return ['linear', scaleLinear().range([254, 0]).domain([minValue, maxValue])]; } /** * @typedef Scale * @property {number | null} [minValue] * @property {number | null} [maxValue] * @property {number | null} [maxRawValue] * @property {number | null} [minRawValue] */ /** * An alternative to Tile. Perhaps the worst data type. An array of numbers with some extra properties. * @typedef {Array<number> & Pick<Tile, 'mirrored' | 'tilePositionId'>} TilePositionArrayObject */ /** * @typedef Tile * @property {string} tileId * @property {TileData} tileData * @property {string} remoteId * @property {unknown} mirrored * @property {string} tilePositionId * @property {import("pixi.js").Graphics} graphics */ /** * @typedef TileData * @property {string} tilesetUid * @property {number} zoomLevel * @property {Array<number>} tilePos * @property {string} error * @property {Float32Array} dense * @property {number} minNonZero * @property {number} maxNonZero * @property {Array<number>} [shape] - Optional 1D or 2D array dimensions */ /** * @typedef TiledPixiTrackContextBase * @property {DataFetcher} dataFetcher * @property {t.DataConfig} dataConfig * @property {function} animate A function to redraw this track. Typically called when an * asynchronous event occurs (i.e. tiles loaded) * @property {() => void} onValueScaleChanged The range of values has changed so we need to inform * the higher ups that the value scale has changed. Only occurs on tracks with ``dense`` data. * @property {(t: t.TilesetInfo) => void} [handleTilesetInfoReceived] A callback to do something once once the tileset * info is received. Usually it registers some information about the tileset with its definition */ /** * @typedef {import('./PixiTrack').ExtendedPixiContext<TiledPixiTrackContextBase>} TiledPixiTrackContext */ /** * @typedef TiledPixiTrackOptions * @property {string} labelPosition - If the label is to be drawn, where should it be drawn? * @property {string} labelText - What should be drawn in the label. * @property {number} maxZoom * @property {string} name */ /** * @template T * @typedef {T & TiledPixiTrackOptions} ExtendedTiledPixiTrackOptions */ /** * The TiledPixiTrack requires an options parameter, which should be an object containing properties specified in * TiledPixiTrackOptions. It is capable of accepting any property defined in any of its superclasses. * @template {ExtendedTiledPixiTrackOptions<{[key: string]: any}>} Options * @extends {PixiTrack<Options>} * */ class TiledPixiTrack extends PixiTrack { /** * * @param {TiledPixiTrackContext} context * @param {Options} options */ constructor(context, options) { super(context, options); const { pubSub, dataConfig, handleTilesetInfoReceived, animate, onValueScaleChanged, } = context; // keep track of which render we're on so that we save ourselves // rerendering all rendering in the same version will have the same // scaling so tiles rendered in the same version will have the same // output. Mostly useful for heatmap tiles. /** @type {number} */ this.renderVersion = 1; // the tiles which should be visible (although they're not necessarily fetched) /** @type {Array<Pick<Tile, 'tileId' |'remoteId' | 'mirrored'>>} */ this.visibleTiles = []; /** @type {Set<string>} */ this.visibleTileIds = new Set(); // keep track of tiles that are currently being rendered this.renderingTiles = new Set(); // the tiles we already have requests out for this.fetching = new Set(); /** @type {Scale} */ this.scale = {}; // tiles we have fetched and ready to be rendered /** @type {{[id: string]: Tile}} */ this.fetchedTiles = {}; // the graphics that have already been drawn for this track /** @type {Object.<string, import('pixi.js').DisplayObject>} */ this.tileGraphics = {}; /** @type {number} */ this.maxZoom = 0; this.medianVisibleValue = null; this.backgroundTaskScheduler = backgroundTaskScheduler; // If the browser supports requestIdleCallback we use continuous // instead of tile based scaling this.continuousScaling = 'requestIdleCallback' in window; this.valueScaleMin = null; this.fixedValueScaleMin = null; this.valueScaleMax = null; this.fixedValueScaleMax = null; /** @type {Record<string, Array<Function>>} */ this.listeners = {}; /** @type {import('pub-sub-es').PubSub & { __fake__?: boolean }} */ // @ts-expect-error This is always defined in Track.js this.pubSub = pubSub; this.animate = animate; this.onValueScaleChanged = onValueScaleChanged; // store the server and tileset uid so they can be used in draw() // if the tileset info is not found this.prevValueScale = null; if (!context.dataFetcher) { this.dataFetcher = new DataFetcher(dataConfig, this.pubSub); } else { this.dataFetcher = context.dataFetcher; } // To indicate that this track is requiring a tileset info /** @type {t.TilesetInfo} */ // @ts-expect-error This has to be initialized to null this.tilesetInfo = null; /** @type {null | string} */ this.tilesetInfoError = null; this.uuid = slugid.nice(); // this needs to be above the tilesetInfo() call because if that // executes first, the call to draw() will complain that this text // doesn't exist this.trackNotFoundText = new GLOBALS.PIXI.Text('', { fontSize: '12px', fontFamily: 'Arial', fill: 'black', }); this.pLabel.addChild(this.trackNotFoundText); this.refreshTilesDebounced = throttleAndDebounce( this.refreshTiles.bind(this), ZOOM_DEBOUNCE, ZOOM_DEBOUNCE, ); this.dataFetcher.tilesetInfo((tilesetInfo, tilesetUid) => { if (!tilesetInfo) return; if (isTilesetInfo(tilesetInfo)) { this.tilesetInfo = tilesetInfo; if (this.tilesetInfo.chromsizes) { // We got chromosome info from the tileset info so let's parse it // into an object we can use this.chromInfo = parseChromsizesRows(this.tilesetInfo.chromsizes); } } else { // no tileset info for this track console.warn( 'Error retrieving tilesetInfo:', dataConfig, tilesetInfo.error, ); if (tilesetInfo.error) { this.setError(tilesetInfo.error, 'dataFetcher.tilesetInfo'); } // Fritz: Not sure why it's reset // this.trackNotFoundText = ''; this.tilesetInfo = undefined; return; } // If the dataConfig contained a fileUrl, then // we need to update the tilesetUid based // on the registration of the fileUrl. if (!this.dataFetcher.dataConfig.tilesetUid) { this.dataFetcher.dataConfig.tilesetUid = tilesetUid; } this.tilesetUid = this.dataFetcher.dataConfig.tilesetUid; this.server = this.dataFetcher.dataConfig.server || 'unknown'; if (this.tilesetInfo?.chromsizes) { this.chromInfo = parseChromsizesRows(this.tilesetInfo.chromsizes); } if (isResolutionsTilesetInfo(this.tilesetInfo)) { this.maxZoom = this.tilesetInfo.resolutions.length; } else { this.maxZoom = +this.tilesetInfo.max_zoom; } if (this.options?.maxZoom) { if (this.options.maxZoom >= 0) { this.maxZoom = Math.min(this.options.maxZoom, this.maxZoom); } else { console.error('Invalid maxZoom on track:', this); } } this.refreshTiles(); // Let this track know that tileset info was received this.tilesetInfoReceived(); // Let external listeners know that tileset info was received handleTilesetInfoReceived?.(this.tilesetInfo); // @ts-expect-error This should never happen since options is set in Track this.options ??= {}; this.options.name = this.options.name || this.tilesetInfo.name; this.checkValueScaleLimits(); this.draw(); this.drawLabel(); // draw the label so that the current resolution is displayed this.animate(); }); } /** * @param {string} error * @param {string} source */ setError(error, source) { this.errorTexts[source] = error; this.drawError(); } /** @param {number | string} value */ setFixedValueScaleMin(value) { if (!Number.isNaN(+value)) this.fixedValueScaleMin = +value; else this.fixedValueScaleMin = null; } /** @param {number | string} value */ setFixedValueScaleMax(value) { if (!Number.isNaN(+value)) this.fixedValueScaleMax = +value; else this.fixedValueScaleMax = null; } checkValueScaleLimits() { this.valueScaleMin = typeof this.options.valueScaleMin !== 'undefined' ? +this.options.valueScaleMin : null; if (this.fixedValueScaleMin !== null) { this.valueScaleMin = this.fixedValueScaleMin; } this.valueScaleMax = typeof this.options.valueScaleMax !== 'undefined' ? +this.options.valueScaleMax : null; if (this.fixedValueScaleMax !== null) { this.valueScaleMax = this.fixedValueScaleMax; } } /** * Register an event listener for track events. Currently, the only supported * event is ``dataChanged``. * * @param {string} event The event to listen for * @param {function} callback The callback to call when the event occurs. The * parameters for the event depend on the event called. * * @example * * trackObj.on('dataChanged', (newData) => { * console.log('newData:', newData) * }); */ on(event, callback) { if (!this.listeners[event]) { this.listeners[event] = []; } this.listeners[event].push(callback); } /** * @param {string} event The event to listen for * @param {function} callback The callback to call when the event occurs. The * parameters for the event depend on the event called. */ off(event, callback) { const id = this.listeners[event].indexOf(callback); if (id === -1 || id >= this.listeners[event].length) return; this.listeners[event].splice(id, 1); } /** @param {Options} options */ rerender(options) { super.rerender(options); this.renderVersion += 1; if (!this.tilesetInfo) { return; } this.checkValueScaleLimits(); if (isResolutionsTilesetInfo(this.tilesetInfo)) { this.maxZoom = this.tilesetInfo.resolutions.length; } else { this.maxZoom = +this.tilesetInfo.max_zoom; } if (this.options?.maxZoom) { if (this.options.maxZoom >= 0) { this.maxZoom = Math.min(this.options.maxZoom, this.maxZoom); } else { console.error('Invalid maxZoom on track:', this); } } } /** Called when tileset info is received. The actual tileset info * can be found in this.tilesetInfo. * * Child tracks can implement this method. */ tilesetInfoReceived() {} /** * Return the set of ids of all tiles which are both visible and fetched. */ visibleAndFetchedIds() { return Object.keys(this.fetchedTiles).filter((x) => this.visibleTileIds.has(x), ); } visibleAndFetchedTiles() { return this.visibleAndFetchedIds().map((x) => this.fetchedTiles[x]); } /** * Set which tiles are visible right now. * * @param {Array<TilePositionArrayObject>} tilePositions - A set of tiles which will be considered the currently visible tile positions. */ setVisibleTiles(tilePositions) { this.visibleTiles = tilePositions.map((x) => ({ // @ts-expect-error Classes which extend TiledPixiTrack have this tileId: this.tileToLocalId(x), // @ts-expect-error Classes which extend TiledPixiTrack have this remoteId: this.tileToRemoteId(x), mirrored: x.mirrored, })); this.visibleTileIds = new Set(this.visibleTiles.map((x) => x.tileId)); } removeOldTiles() { // @ts-expect-error Classes which extend TiledPixiTrack have this this.calculateVisibleTiles(); // tiles that are fetched const fetchedTileIDs = new Set(Object.keys(this.fetchedTiles)); // // calculate which tiles are obsolete and remove them // fetchedTileID are remote ids const toRemove = [...fetchedTileIDs].filter( (x) => !this.visibleTileIds.has(x), ); this.removeTiles(toRemove); } refreshTiles() { if (!this.tilesetInfo) { return; } // @ts-expect-error Classes which extend TiledPixiTrack have this this.calculateVisibleTiles(); // tiles that are fetched const fetchedTileIDs = new Set(Object.keys(this.fetchedTiles)); // fetch the tiles that should be visible but haven't been fetched // and aren't in the process of being fetched const toFetch = [...this.visibleTiles].filter( (x) => !this.fetching.has(x.remoteId) && !fetchedTileIDs.has(x.tileId), ); for (let i = 0; i < toFetch.length; i++) { this.fetching.add(toFetch[i].remoteId); } this.removeOldTiles(); this.fetchNewTiles(toFetch); } /** @param {Tile} tile */ parentInFetched(tile) { const uid = tile.tileData.tilesetUid; let zl = tile.tileData.zoomLevel; let pos = tile.tileData.tilePos; while (zl > 0) { zl -= 1; pos = pos.map((x) => Math.floor(x / 2)); const parentId = `${uid}.${zl}.${pos.join('.')}`; if (parentId in this.fetchedTiles) { return true; } } return false; } /** @param {Tile} tile */ parentTileId(tile) { const parentZoomLevel = tile.tileData.zoomLevel - 1; const parentPos = tile.tileData.tilePos.map((x) => Math.floor(x / 2)); const parentUid = tile.tileData.tilesetUid; return `${parentUid}.${parentZoomLevel}.${parentPos.join('.')}`; } /** * Remove obsolete tiles * * @param {Array<string>} toRemoveIds: An array of tile ids to remove from the list of fetched tiles. */ removeTiles(toRemoveIds) { // if there's nothing to remove, don't bother doing anything if ( !toRemoveIds.length || !this.areAllVisibleTilesLoaded() || this.renderingTiles.size ) { return; } toRemoveIds.forEach((x) => { const tileIdStr = x; this.destroyTile(this.fetchedTiles[tileIdStr]); if (tileIdStr in this.tileGraphics) { this.pMain.removeChild(this.tileGraphics[tileIdStr]); delete this.tileGraphics[tileIdStr]; } delete this.fetchedTiles[tileIdStr]; }); this.synchronizeTilesAndGraphics(); this.draw(); } /** * @param {t.Scale} newXScale * @param {t.Scale} newYScale */ zoomed(newXScale, newYScale, k = 1, tx = 0, ty = 0) { this.xScale(newXScale); this.yScale(newYScale); // @ts-expect-error Not sure why this is called without an argument this.refreshTilesDebounced(); this.pMobile.position.x = tx; this.pMobile.position.y = this.position[1]; this.pMobile.scale.x = k; this.pMobile.scale.y = 1; } /** @param {[number, number]} newPosition */ setPosition(newPosition) { super.setPosition(newPosition); // this.draw(); } /** @param {[number, number]} newDimensions */ setDimensions(newDimensions) { super.setDimensions(newDimensions); // this.draw(); } /** * Check to see if all the visible tiles are loaded. * * If they are, remove all other tiles. */ areAllVisibleTilesLoaded() { // tiles that are fetched const fetchedTileIDs = new Set(Object.keys(this.fetchedTiles)); const visibleTileIdsList = [...this.visibleTileIds]; for (let i = 0; i < visibleTileIdsList.length; i++) { if (!fetchedTileIDs.has(visibleTileIdsList[i])) { return false; } } return true; } /** * Function is called when all tiles that should be visible have * been received. */ allTilesLoaded() {} /** @param {number} [_] - Optional value to set */ minValue(_) { if (_) { this.scale.minValue = _; return this; } return this.valueScaleMin !== null ? this.valueScaleMin : this.scale.minValue; } /** @param {number} [_] - Optional value to set */ maxValue(_) { if (_) { this.scale.maxValue = _; return this; } return this.valueScaleMax !== null ? this.valueScaleMax : this.scale.maxValue; } minRawValue() { // this is the minimum value from all the tiles that // hasn't been externally modified by locked scales return this.scale.minRawValue; } maxRawValue() { // this is the maximum value from all the tiles that // hasn't been externally modified by locked scales return this.scale.maxRawValue; } /** @param {Tile} tile */ initTile(tile) { if (!tile.tileData?.dense) { return; } // create the tile // should be overwritten by child classes this.scale.minRawValue = this.continuousScaling ? this.minVisibleValue() : this.minVisibleValueInTiles(); this.scale.maxRawValue = this.continuousScaling ? this.maxVisibleValue() : this.maxVisibleValueInTiles(); this.scale.minValue = this.scale.minRawValue; this.scale.maxValue = this.scale.maxRawValue; } /** @param {Tile} tile */ updateTile(tile) {} /** @param {Tile} tile */ destroyTile(tile) { // remove all data structures needed to draw this tile } addMissingGraphics() { /** * Add graphics for tiles that have no graphics */ const fetchedTileIDs = Object.keys(this.fetchedTiles); this.renderVersion += 1; for (let i = 0; i < fetchedTileIDs.length; i++) { if (!(fetchedTileIDs[i] in this.tileGraphics)) { // console.trace('adding:', fetchedTileIDs[i]); const newGraphics = new GLOBALS.PIXI.Graphics(); this.pMain.addChild(newGraphics); this.fetchedTiles[fetchedTileIDs[i]].graphics = newGraphics; this.initTile(this.fetchedTiles[fetchedTileIDs[i]]); this.tileGraphics[fetchedTileIDs[i]] = newGraphics; } } /* if (added) this.draw(); */ } /** * Change the graphics for existing tiles */ updateExistingGraphics() { const fetchedTileIDs = Object.keys(this.fetchedTiles); for (let i = 0; i < fetchedTileIDs.length; i++) { const tile = this.fetchedTiles[fetchedTileIDs[i]]; this.updateTile(tile); } } synchronizeTilesAndGraphics() { /** * Make sure that we have a one to one mapping between tiles * and graphics objects * */ // keep track of which tiles are visible at the moment this.addMissingGraphics(); this.removeOldTiles(); this.updateExistingGraphics(); if (this.listeners.dataChanged) { for (const callback of this.listeners.dataChanged) { callback(this.visibleAndFetchedTiles().map((x) => x.tileData)); } } } /** * @typedef TiledAreaTile * @property {string} tileId * @property {string} type * @property {unknown} data */ /** * * @param {TiledAreaTile} tile A tile returned by a TiledArea. * @param {function} dataLoader A function for extracting drawable data from a tile. This * usually means differentiating the between dense and sparse tiles and putting the data into an array. * @returns */ loadTileData(tile, dataLoader) { /** * Extract drawable data from a tile loaded by a generic tile loader * * @param tile: A tile returned by a TiledArea. * @param dataLoader: A function for extracting drawable data from a tile. This * usually means differentiating the between dense and sparse * tiles and putting the data into an array. */ // see if the data is already cached // @ts-expect-error this.lruCache exists in classes that extend this one let loadedTileData = this.lruCache.get(tile.tileId); // if not, load it and put it in the cache if (!loadedTileData) { loadedTileData = dataLoader(tile.data, tile.type); // @ts-expect-error this.lruCache exists in classes that extend this one this.lruCache.put(tile.tileId, loadedTileData); } return loadedTileData; } /** @param {Pick<Tile,'remoteId'>[]} toFetch */ fetchNewTiles(toFetch) { this._checkForErrors(); this.draw(); if (toFetch.length > 0) { const toFetchList = [...new Set(toFetch.map((x) => x.remoteId))]; this.dataFetcher.fetchTilesDebounced( this.receivedTiles.bind(this), toFetchList, ); } } /** * We've gotten a bunch of tiles from the server in * response to a request from fetchTiles. * @param {Object<string, import('./data-fetchers/DataFetcher').DividedTile | Tile | TilePositionArrayObject>} loadedTiles */ receivedTiles(loadedTiles) { for (let i = 0; i < this.visibleTiles.length; i++) { const { tileId } = this.visibleTiles[i]; if (!loadedTiles[this.visibleTiles[i].remoteId]) continue; if (this.visibleTiles[i].remoteId in loadedTiles) { if (!(tileId in this.fetchedTiles)) { // this tile may have graphics associated with it // @ts-expect-error more properties will be added to this.fetchedTiles[tileId] later (such as by synchronizeTilesAndGraphics()) this.fetchedTiles[tileId] = this.visibleTiles[i]; } // Fritz: Store a shallow copy. If necessary we perform a deep copy of // the dense data in `tile-proxy.js :: tileDataToPixData()` // Somehow 2d rectangular domain tiles do not come in the flavor of an // object but an object array... if (Array.isArray(loadedTiles[this.visibleTiles[i].remoteId])) { const tileData = loadedTiles[this.visibleTiles[i].remoteId]; // @ts-expect-error this.fetchedTiles[tileId].tileData will get more defined in the next lines this.fetchedTiles[tileId].tileData = [...tileData]; // Fritz: this is sooo hacky... we should really not use object arrays Object.keys(tileData) .filter((key) => Number.isNaN(+key)) .forEach((key) => { // @ts-expect-error Since tileData is an array, the properties have to be copied over manually this.fetchedTiles[tileId].tileData[key] = tileData[key]; }); } else { // @ts-expect-error The object doesn't at this point have all of the properties that it will have later this.fetchedTiles[tileId].tileData = { ...loadedTiles[this.visibleTiles[i].remoteId], }; } if (this.fetchedTiles[tileId].tileData.error) { console.warn( 'Error in loaded tile', tileId, this.fetchedTiles[tileId].tileData, ); } } } // const fetchedTileIDs = new Set(Object.keys(this.fetchedTiles)); for (const key in loadedTiles) { if (loadedTiles[key]) { const tileId = loadedTiles[key].tilePositionId; if (this.fetching.has(tileId)) { this.fetching.delete(tileId); } } } /* * Mainly called to remove old unnecessary tiles */ this.synchronizeTilesAndGraphics(); // we need to draw when we receive new data this.draw(); this.drawLabel(); // update the current zoom level // Let HiGlass know we need to re-render // check if the value scale has changed // @ts-expect-error This is defined by classes which extend this one if (this.valueScale) { if ( !this.prevValueScale || // @ts-expect-error This is defined by classes which extend this one JSON.stringify(this.valueScale.domain()) !== JSON.stringify(this.prevValueScale.domain()) ) { // @ts-expect-error This is defined by classes which extend this one this.prevValueScale = this.valueScale.copy(); if (this.onValueScaleChanged) { // this is used to synchronize tracks with locked value scales this.onValueScaleChanged(); } } } this.animate(); // 1. Check if all visible tiles are loaded // 2. If `true` then send out event if (this.areAllVisibleTilesLoaded()) { this.pubSub?.publish('TiledPixiTrack.tilesLoaded', { uuid: this.uuid }); } } _checkForErrors() { const errors = Object.values(this.fetchedTiles) .map((x) => x.tileData?.error && `${x.tileId}: ${x.tileData.error}`) .filter((x) => x); if (errors.length) { this.errorTexts.TiledPixiTrack = errors.join('\n'); } else { this.errorTexts.TiledPixiTrack = ''; } if (this.tilesetInfoError) { this.errorTexts.TiledPixiTrack = this.tilesetInfoError; errors.push(this.tilesetInfoError); } return errors; } draw() { if (this.delayDrawing) return; if (!this.tilesetInfo) { if (this.dataFetcher.tilesetInfoLoading) { this.trackNotFoundText.text = 'Loading...'; } else { const server = this.dataFetcher.dataConfig.server || 'unknown'; const tilesetUid = this.dataFetcher.dataConfig.tilesetUid; this.trackNotFoundText.text = `Tileset info not found. Server: [${server}] tilesetUid: [${tilesetUid}]`; } [this.trackNotFoundText.x, this.trackNotFoundText.y] = this.position; /* if (this.flipText) this.trackNotFoundText.scale.x = -1; */ this.trackNotFoundText.visible = true; } else { this.trackNotFoundText.visible = false; } this.pubSub?.publish('TiledPixiTrack.tilesDrawnStart', { uuid: this.uuid }); this._checkForErrors(); super.draw(); Object.keys(this.fetchedTiles).forEach((tilesetUid) => { this.drawTile(this.fetchedTiles[tilesetUid]); }); this.pubSub?.publish('TiledPixiTrack.tilesDrawnEnd', { uuid: this.uuid }); } /** * Draw a tile on some graphics * @param {Tile} tile */ drawTile(tile) {} calculateMedianVisibleValue() { if (this.areAllVisibleTilesLoaded()) { this.allTilesLoaded(); } let visibleAndFetchedIds = this.visibleAndFetchedIds(); if (visibleAndFetchedIds.length === 0) { visibleAndFetchedIds = Object.keys(this.fetchedTiles); } const values = /** @type {Array<number>} */ ([]) .concat( ...visibleAndFetchedIds .filter((x) => this.fetchedTiles[x].tileData.dense) .map((x) => Array.from(this.fetchedTiles[x].tileData.dense)), ) .filter((x) => x > 0); this.medianVisibleValue = median(values); return this.medianVisibleValue; } allVisibleValues() { return /** @type {Array<number>} */ ([]).concat( ...this.visibleAndFetchedIds().map((x) => Array.from(this.fetchedTiles[x].tileData.dense), ), ); } // Should be overwriten by child clases to get the true minimal // visible value in the currently viewed area minVisibleValue(ignoreFixedScale = false) { return this.minVisibleValueInTiles(ignoreFixedScale); } minVisibleValueInTiles(ignoreFixedScale = false) { // Get minimum in currently visible tiles let visibleAndFetchedIds = this.visibleAndFetchedIds(); if (visibleAndFetchedIds.length === 0) { visibleAndFetchedIds = Object.keys(this.fetchedTiles); } /** @type {number | null} */ let min = Math.min( ...visibleAndFetchedIds.map( (x) => this.fetchedTiles[x].tileData.minNonZero, ), ); // if there's no data, use null if (min === Number.MAX_SAFE_INTEGER) { min = null; } if (ignoreFixedScale) return min; return this.valueScaleMin !== null ? this.valueScaleMin : min; } // Should be overwriten by child clases to get the true maximal // visible value in the currently viewed area maxVisibleValue(ignoreFixedScale = false) { return this.maxVisibleValueInTiles(ignoreFixedScale); } maxVisibleValueInTiles(ignoreFixedScale = false) { // Get maximum in currently visible tiles let visibleAndFetchedIds = this.visibleAndFetchedIds(); if (visibleAndFetchedIds.length === 0) { visibleAndFetchedIds = Object.keys(this.fetchedTiles); } /** @type {number | null} */ let max = Math.max( ...visibleAndFetchedIds.map( (x) => this.fetchedTiles[x].tileData.maxNonZero, ), ); // if there's no data, use null if (max === Number.MIN_SAFE_INTEGER) { max = null; } if (ignoreFixedScale) return max; return this.valueScaleMax !== null ? this.valueScaleMax : max; } /** @typedef {import('d3-scale').ScaleQuantile<number, never> & { ticks?: (count: number) => number[] }} ScaleQuantile */ /** * Create a value scale that will be used to position values * along the y axis. * @param {number} minValue The minimum value of the data * @param {number} medianValue The median value of the data. Potentially used for adding a pseudocount * @param {number} maxValue The maximum value of the data * @param {number} [inMargin] A number of pixels to be left free on the top and bottom * of the track. For example if the glyphs have a certain * width and we want all of them to fit into the space * @returns {[t.Scale | ScaleQuantile, number]} */ makeValueScale(minValue, medianValue, maxValue, inMargin) { let valueScale = null; let offsetValue = 0; let margin = inMargin; if (margin === null || typeof margin === 'undefined') { margin = 6; // set a default value } let minDimension = Math.min(this.dimensions[1] - margin, margin); let maxDimension = Math.max(this.dimensions[1] - margin, margin); if (this.dimensions[1] - margin < margin) { // if the track becomes smaller than the margins, then just draw a flat // line in the center minDimension = this.dimensions[1] / 2; maxDimension = this.dimensions[1] / 2; } if (this.options.valueScaling === 'log') { offsetValue = medianValue; if (!offsetValue) { offsetValue = minValue; } valueScale = scaleLog() // .base(Math.E) .domain([offsetValue, maxValue + offsetValue]) // .domain([offsetValue, this.maxValue()]) .range([maxDimension, minDimension]); // pseudocount = offsetValue; } else if (this.options.valueScaling === 'quantile') { const start = this.dimensions[1] - margin; const end = margin; /** @type {ScaleQuantile} */ const quantScale = scaleQuantile() .domain(this.allVisibleValues()) .range(range(start, end, (end - start) / 256)); quantScale.ticks = (n) => ticks(start, end, n); return [quantScale, 0]; } else if (this.options.valueScaling === 'setquantile') { const start = this.dimensions[1] - margin; const end = margin; const s = new Set(this.allVisibleValues()); /** @type {ScaleQuantile} */ const quantScale = scaleQuantile() .domain([...s]) .range(range(start, end, (end - start) / 256)); quantScale.ticks = (n) => ticks(start, end, n); return [quantScale, 0]; } else { // linear scale valueScale = scaleLinear() .domain([minValue, maxValue]) .range([maxDimension, minDimension]); } return [valueScale, offsetValue]; } } export default TiledPixiTrack;