higlass
Version:
HiGlass Hi-C / genomic / large data viewer
476 lines (410 loc) • 14.7 kB
JavaScript
// @ts-nocheck
import { zoomIdentity } from 'd3-zoom';
import AxisPixi from './AxisPixi';
/**
* The d3.range and python range functinons. Returns
* an array of consecutive integers between start and end.
*
* @param {number} start Lower limit (included in result)
* @param {number} end Upper limit (not included in result)
* @return {array} A list of consecutive integers from start to end
*/
const range = (start, end) => {
const values = [];
for (let i = start; i < end; i++) {
values.push(i);
}
return values;
};
/**
* Calculate the zoom level from a list of available resolutions
*
* @param {array} resolutions A list of data resolutions (e.g. [100,1000])
* @param {function} scale The current D3 scale function describing the domain and range
* of the view.
*/
const calculateZoomLevelFromResolutions = (resolutions, scale) => {
const sortedResolutions = resolutions.map((x) => +x).sort((a, b) => b - a);
const trackWidth = scale.range()[1] - scale.range()[0];
const binsDisplayed = sortedResolutions.map(
(r) => (scale.domain()[1] - scale.domain()[0]) / r,
);
const binsPerPixel = binsDisplayed.map((b) => b / trackWidth);
// we're going to show the highest resolution that requires more than one
// pixel per bin
const displayableBinsPerPixel = binsPerPixel.filter((b) => b < 1);
if (displayableBinsPerPixel.length === 0) return 0;
return binsPerPixel.indexOf(
displayableBinsPerPixel[displayableBinsPerPixel.length - 1],
);
};
/**
* Calculate the current zoom level from powers of two resolutions.
*
* @param {function} scale The current D3 scale function used for the view.
* @param {number} minX The minimum possible X value
* @param {number} maxX The maximum possible x value
* @param {number} binsPerTile The width of each tile in whatever
* unit the x-axis is (bp for genomic data).
*/
const calculateZoomLevel = (scale, minX, maxX, binsPerTile) => {
const rangeWidth = scale.range()[1] - scale.range()[0];
const zoomScale = Math.max(
(maxX - minX) / (scale.domain()[1] - scale.domain()[0]),
1,
);
// fun fact: the number 384 is halfway between 256 and 512
// this constant determines the maximum number of pixels that
// a tile can span
const VIEW_RESOLUTION = 384;
const addedZoom = Math.max(
0,
Math.ceil(Math.log(rangeWidth / VIEW_RESOLUTION) / Math.LN2),
);
let zoomLevel = Math.round(Math.log(zoomScale) / Math.LN2) + addedZoom;
let binsPerTileCorrection = 0;
if (binsPerTile) {
binsPerTileCorrection = Math.floor(
Math.log(256) / Math.log(2) - Math.log(binsPerTile) / Math.log(2),
);
}
zoomLevel += binsPerTileCorrection;
return zoomLevel;
};
/**
* Calculate the current zoom level for a 1D track
*
* @param {object} tilesetInfo The tileset info for the track. Should contain
* min_pos and max_pos arrays, each of which has one
* value which stores the minimum and maximum data
* positions respectively.
* @param {function} xScale The current D3 scale function for the track.
* @param {number} maxZoom The maximum zoom level allowed by the track.
* @return {number} The current zoom level of the track.
*/
const calculate1DZoomLevel = (tilesetInfo, xScale, maxZoom) => {
if (typeof maxZoom === 'undefined') {
maxZoom = Number.MAX_SAFE_INTEGER;
}
// offset by 2 because 1D tiles are more dense than 2D tiles
// 1024 points per tile vs 256 for 2D tiles
if (tilesetInfo.resolutions) {
const zoomIndexX = calculateZoomLevelFromResolutions(
tilesetInfo.resolutions,
xScale,
tilesetInfo.min_pos[0],
tilesetInfo.max_pos[0] - 2,
);
return zoomIndexX;
}
// the tileProxy calculateZoomLevel function only cares about the
// difference between the minimum and maximum position
const xZoomLevel = calculateZoomLevel(
xScale,
tilesetInfo.min_pos[0],
tilesetInfo.max_pos[0],
tilesetInfo.bins_per_dimension || tilesetInfo.tile_size,
);
const zoomLevel = Math.min(xZoomLevel, maxZoom);
return Math.max(zoomLevel, 0);
};
/**
* Calculate the tiles that should be visible get a data domain
* and a tileset info
*
* All the parameters except the first should be present in the
* tileset_info returned by the server.
*
* @param zoomLevel: The zoom level at which to find the tiles (can be
* calculated using this.calculateZoomLevel, but needs to synchronized across
* both x and y scales so should be calculated externally)
* @param scale: A d3 scale mapping data domain to visible values
* @param minX: The minimum possible value in the dataset
* @param maxX: The maximum possible value in the dataset
* @param maxZoom: The maximum zoom value in this dataset
* @param maxDim: The largest dimension of the tileset (e.g., width or height)
* (roughlty equal to 2 ** maxZoom * tileSize * tileResolution)
*/
const calculateTiles = (zoomLevel, scale, minX, maxX, maxZoom, maxDim) => {
const zoomLevelFinal = Math.min(zoomLevel, maxZoom);
// the ski areas are positioned according to their
// cumulative widths, which means the tiles need to also
// be calculated according to cumulative width
const tileWidth = maxDim / 2 ** zoomLevelFinal;
const epsilon = 0.0000001;
return range(
Math.max(0, Math.floor((scale.domain()[0] - minX) / tileWidth)),
Math.min(
2 ** zoomLevelFinal,
Math.ceil((scale.domain()[1] - minX - epsilon) / tileWidth),
),
);
};
/**
* Calculate the tiles that sould be visisble given the resolution and
* the minX and maxX values for the region
*
* @param resolution: The number of base pairs per bin
* @param scale: The scale to use to calculate the currently visible tiles
* @param minX: The minimum x position of the tileset
* @param maxX: The maximum x position of the tileset
*/
const calculateTilesFromResolution = (
resolution,
scale,
minX,
maxX = Number.MAX_VALUE,
pixelsPerTile = 256,
) => {
const epsilon = 0.0000001;
const tileWidth = resolution * pixelsPerTile;
const MAX_TILES = 20;
const lowerBound = Math.max(
0,
Math.floor((scale.domain()[0] - minX) / tileWidth),
);
const upperBound = Math.ceil(
Math.min(maxX, scale.domain()[1] - minX - epsilon) / tileWidth,
);
let tileRange = range(lowerBound, upperBound);
if (tileRange.length > MAX_TILES) {
// too many tiles visible in this range
console.warn(
`Too many visible tiles: ${tileRange.length} truncating to ${MAX_TILES}`,
);
tileRange = tileRange.slice(0, MAX_TILES);
}
return tileRange;
};
/**
* Calculate which tiles should be visible given a track's
* scale.
*
* @param {object} tilesetInfo The track's tileset info, containing either the `resolutions`
* list or min_pos and max_pos arrays
* @param {function} scale The track's D3 scale function.
* @return {array} A list of visible tiles (e.g. [[1,0],[1,1]])
*/
const calculate1DVisibleTiles = (tilesetInfo, scale) => {
// if we don't know anything about this dataset, no point
// in trying to get tiles
if (!tilesetInfo) {
return [];
}
// calculate the zoom level given the scales and the data bounds
const zoomLevel = calculate1DZoomLevel(
tilesetInfo,
scale,
tilesetInfo.max_zoom,
);
if (tilesetInfo.resolutions) {
const sortedResolutions = tilesetInfo.resolutions
.map((x) => +x)
.sort((a, b) => b - a);
const xTiles = calculateTilesFromResolution(
sortedResolutions[zoomLevel],
scale,
tilesetInfo.min_pos[0],
tilesetInfo.max_pos[0],
);
const tiles = xTiles.map((x) => [zoomLevel, x]);
return tiles;
}
// x doesn't necessary mean 'x' axis, it just refers to the relevant axis
// (x if horizontal, y if vertical)
const xTiles = calculateTiles(
zoomLevel,
scale,
tilesetInfo.min_pos[0],
tilesetInfo.max_pos[0],
tilesetInfo.max_zoom,
tilesetInfo.max_width,
);
const tiles = xTiles.map((x) => [zoomLevel, x]);
return tiles;
};
/**
* Draw an axis on a track. Where on the track the axis will be drawn
* is taken from the track's options.
*
* @param {PixiTrack} track The track to decorate with an axis.
* @param {d3.scale} valueScale The scale that the axis should draw.
*/
const drawAxis = (track, valueScale) => {
if (!track.axis) {
track.axis = new AxisPixi(track);
track.pBase.addChild(track.axis.pAxis);
}
// either no axis position is specified
if (
!track.options.axisPositionVertical &&
!track.options.axisPositionHorizontal
) {
track.axis.clearAxis();
return;
}
if (
track.options.axisPositionVertical &&
track.options.axisPositionVertical === 'hidden'
) {
track.axis.clearAxis();
return;
}
if (
track.options.axisPositionHorizontal &&
track.options.axisPositionHorizontal === 'hidden'
) {
track.axis.clearAxis();
return;
}
const margin = track.options.axisMargin || 0;
if (
track.options.axisPositionHorizontal === 'left' ||
track.options.axisPositionVertical === 'top'
) {
// left axis are shown at the beginning of the plot
track.axis.pAxis.position.x = track.position[0] + margin;
track.axis.pAxis.position.y = track.position[1];
track.axis.drawAxisRight(valueScale, track.dimensions[1]);
} else if (
track.options.axisPositionHorizontal === 'outsideLeft' ||
track.options.axisPositionVertical === 'outsideTop'
) {
// left axis are shown at the beginning of the plot
track.axis.pAxis.position.x = track.position[0] + margin;
track.axis.pAxis.position.y = track.position[1];
track.axis.drawAxisLeft(valueScale, track.dimensions[1]);
} else if (
track.options.axisPositionHorizontal === 'right' ||
track.options.axisPositionVertical === 'bottom'
) {
track.axis.pAxis.position.x =
track.position[0] + track.dimensions[0] - margin;
track.axis.pAxis.position.y = track.position[1];
track.axis.drawAxisLeft(valueScale, track.dimensions[1]);
} else if (
track.options.axisPositionHorizontal === 'outsideRight' ||
track.options.axisPositionVertical === 'outsideBottom'
) {
track.axis.pAxis.position.x =
track.position[0] + track.dimensions[0] - margin;
track.axis.pAxis.position.y = track.position[1];
track.axis.drawAxisRight(valueScale, track.dimensions[1]);
}
};
/**
* A track is being dragged along it's value scale axis.
*
* @param {object} Track The track instance being dragged.
* @param {number} dY The change in y position.
*/
const movedY = (track, dY) => {
// see the reasoning behind why the code in
// zoomedY is commented out.
const vst = track.valueScaleTransform;
const { y, k } = vst;
const height = track.dimensions[1];
// clamp at the bottom and top
if (y + dY / k > -(k - 1) * height && y + dY / k < 0) {
track.valueScaleTransform = vst.translate(0, dY / k);
}
Object.values(track.fetchedTiles).forEach((tile) => {
tile.graphics.position.y = track.valueScaleTransform.y;
});
track.animate();
};
/**
* A track has received an event telling it to zoom along its
* vertical axis. Update the transform describing the position
* of its graphics.
*
* @param {number} yPos The position the zoom event took place
* @param {number} kMultiplier How much the zoom level should be adjusted by
* @param {d3.transform} transform The track's current graphics transform.
* @param {number} height The height of the track
* @return {d3.transform} The track's new graphics transform.
*/
const zoomedY = (yPos, kMultiplier, transform, height) => {
const k0 = transform.k;
const t0 = transform.y;
const dp = (yPos - t0) / k0;
const k1 = Math.max(k0 / kMultiplier, 1.0);
let t1 = k0 * dp + t0 - k1 * dp;
// clamp at the bottom
t1 = Math.max(t1, -(k1 - 1) * height);
// clamp at the top
t1 = Math.min(t1, 0);
// right now, the point at position 162 is at position 0
// 0 = 1 * 162 - 162
//
// we want that when k = 2, that point is still at position
// 0 = 2 * 162 - t1
// ypos = k0 * dp + t0
// dp = (ypos - t0) / k0
// nypos = k1 * dp + t1
// k1 * dp + t1 = k0 * dp + t0
// t1 = k0 * dp +t0 - k1 * dp
return zoomIdentity.translate(0, t1).scale(k1);
};
/**
* Get the tile's position in its coordinate system.
*
* See Tiled1DPIXITrack.js
*/
const getTilePosAndDimensions = (tilesetInfo, tileId) => {
const zoomLevel = +tileId.split('.')[0];
const xTilePos = +tileId.split('.')[1];
// max_width should be substitutable with 2 ** tilesetInfo.max_zoom
const totalWidth = tilesetInfo.max_width;
const minX = tilesetInfo.min_pos[0];
const tileWidth = totalWidth / 2 ** zoomLevel;
const tileX = minX + xTilePos * tileWidth;
return {
tileX,
tileWidth,
};
};
/** When zooming, we want to do fast scaling rather than re-rendering
* but if we do this too much, shapes (particularly arrowheads) get
* distorted. This function keeps track of how stretched the track is
* and redraws it if it becomes too distorted (tileK < 0.5 or tileK > 2) */
function stretchRects(track, graphicsAccessors) {
Object.values(track.fetchedTiles)
// tile hasn't been drawn properly because we likely got some
// bogus data from the server
.forEach((tile) => {
if (!tile.drawnAtScale) return;
const dasRange = tile.drawnAtScale.range();
const tRange = track._xScale.range();
// check to make sure the track extent hasn't changed
if (dasRange[0] !== tRange[0] || dasRange[1] !== tRange[1]) {
track.renderTile(tile);
return;
}
const tileK =
(tile.drawnAtScale.domain()[1] - tile.drawnAtScale.domain()[0]) /
(track._xScale.domain()[1] - track._xScale.domain()[0]);
if (tileK > 2 || tileK < 0.5) {
// too stretched out, needs to be re-rendered
track.renderTile(tile);
} else {
// can be stretched a little bit, just need to set the scale
const newRange = track._xScale.domain().map(tile.drawnAtScale);
const posOffset = newRange[0];
for (const graphicsAccessor of graphicsAccessors) {
graphicsAccessor(tile).scale.x = tileK;
graphicsAccessor(tile).x = -posOffset * tileK;
}
}
});
}
const trackUtils = {
calculate1DVisibleTiles,
calculate1DZoomLevel,
drawAxis,
movedY,
getTilePosAndDimensions,
stretchRects,
zoomedY,
};
export default trackUtils;