higlass
Version:
HiGlass Hi-C / genomic / large data viewer
1,716 lines (1,419 loc) • 54.9 kB
JavaScript
// @ts-nocheck
import { brushY } from 'd3-brush';
import { format } from 'd3-format';
import { scaleLinear } from 'd3-scale';
import { select } from 'd3-selection';
import ndarray from 'ndarray';
import slugid from 'slugid';
import absToChr from './utils/abs-to-chr';
import colorDomainToRgbaArray from './utils/color-domain-to-rgba-array';
import colorToHex from './utils/color-to-hex';
// Utils
import colorToRgba from './utils/color-to-rgba';
import { download } from './utils/download';
import ndarrayAssign from './utils/ndarray-assign';
import ndarrayFlatten from './utils/ndarray-flatten';
import objVals from './utils/obj-vals';
import showMousePosition from './utils/show-mouse-position';
import valueToColor from './utils/value-to-color';
import AxisPixi from './AxisPixi';
import TiledPixiTrack, { getValueScale } from './TiledPixiTrack';
// Services
import {
calculateResolution,
calculateTileWidth,
calculateTiles,
calculateTilesFromResolution,
calculateZoomLevel,
calculateZoomLevelFromResolutions,
tileDataToPixData,
} from './services/tile-proxy';
import { HEATED_OBJECT_MAP } from './configs/colormaps';
import { NUM_PRECOMP_SUBSETS_PER_2D_TTILE } from './configs/dense-data-extrema-config';
import GLOBALS from './configs/globals';
const COLORBAR_MAX_HEIGHT = 200;
const COLORBAR_WIDTH = 10;
const COLORBAR_LABELS_WIDTH = 40;
const COLORBAR_MARGIN = 10;
const BRUSH_WIDTH = COLORBAR_MARGIN;
const BRUSH_HEIGHT = 4;
const BRUSH_COLORBAR_GAP = 1;
const BRUSH_MARGIN = 4;
const SCALE_LIMIT_PRECISION = 5;
const BINS_PER_TILE = 256;
const COLORBAR_AREA_WIDTH =
COLORBAR_WIDTH +
COLORBAR_LABELS_WIDTH +
COLORBAR_MARGIN +
BRUSH_COLORBAR_GAP +
BRUSH_WIDTH +
BRUSH_MARGIN;
class HeatmapTiledPixiTrack extends TiledPixiTrack {
constructor(context, options) {
// Fritz: this smells very hacky!
const newContext = { ...context };
newContext.onValueScaleChanged = () => {
context.onValueScaleChanged();
this.drawColorbar();
};
super(newContext, options);
const {
pubSub,
animate,
svgElement,
onTrackOptionsChanged,
onMouseMoveZoom,
isShowGlobalMousePosition,
isValueScaleLocked,
} = context;
this.pubSub = pubSub;
this.is2d = true;
this.animate = animate;
this.uid = slugid.nice();
this.scaleBrush = brushY();
this.onTrackOptionsChanged = onTrackOptionsChanged;
this.isShowGlobalMousePosition = isShowGlobalMousePosition;
this.isValueScaleLocked = isValueScaleLocked;
// Graphics for drawing the colorbar
this.pColorbarArea = new GLOBALS.PIXI.Graphics();
this.pMasked.addChild(this.pColorbarArea);
this.pColorbar = new GLOBALS.PIXI.Graphics();
this.pColorbarArea.addChild(this.pColorbar);
this.axis = new AxisPixi(this);
this.pColorbarArea.addChild(this.axis.pAxis);
// [[255,255,255,0], [237,218,10,4] ...
// a 256 element array mapping the values 0-255 to rgba values
// not a d3 color scale for speed
this.colorScale = HEATED_OBJECT_MAP;
if (options?.colorRange) {
this.colorScale = colorDomainToRgbaArray(options.colorRange);
}
this.gBase = select(svgElement).append('g');
this.gMain = this.gBase.append('g');
this.gColorscaleBrush = this.gMain.append('g');
this.brushing = false;
this.prevOptions = '';
// Contains information about which part of the upper left tile is visible
this.prevIndUpperLeftTile = '';
/*
chromInfoService
.get(`${dataConfig.server}/chrom-sizes/?id=${dataConfig.tilesetUid}`)
.then((chromInfo) => { this.chromInfo = chromInfo; });
*/
this.onMouseMoveZoom = onMouseMoveZoom;
this.setDataLensSize(11);
this.dataLens = new Float32Array(this.dataLensSize ** 2);
this.mouseMoveHandlerBound = this.mouseMoveHandler.bind(this);
if (this.onMouseMoveZoom) {
this.pubSubs.push(
this.pubSub.subscribe('app.mouseMove', this.mouseMoveHandlerBound),
);
}
if (this.options?.showMousePosition && !this.hideMousePosition) {
this.hideMousePosition = showMousePosition(
this,
this.is2d,
this.isShowGlobalMousePosition(),
);
}
this.prevOptions = JSON.stringify(options);
}
/**
* Mouse move handler
*
* @param {Object} e Event object.
*/
mouseMoveHandler(e) {
if (!this.isWithin(e.x, e.y)) return;
this.mouseX = e.x;
this.mouseY = e.y;
this.mouseMoveZoomHandler();
}
/**
* Mouse move and zoom handler. Is triggered on both events.
*
* @param {Number} absX Absolute X coordinate.
* @param {Number} absY Absolute Y coordinate
*/
mouseMoveZoomHandler(absX = this.mouseX, absY = this.mouseY) {
if (
typeof absX === 'undefined' ||
typeof absY === 'undefined' ||
!this.areAllVisibleTilesLoaded()
)
return;
if (!this.tilesetInfo) {
return;
}
const relX = absX - this.position[0];
const relY = absY - this.position[1];
let data;
let dataLens;
try {
dataLens = this.getVisibleRectangleData(
relX - this.dataLensPadding,
relY - this.dataLensPadding,
this.dataLensSize,
this.dataLensSize,
);
// The center value
data = dataLens.get(this.dataLensPadding, this.dataLensPadding);
} catch (e) {
return;
}
const dim = this.dataLensSize;
let toRgb;
try {
toRgb = valueToColor(
this.limitedValueScale,
this.colorScale,
this.valueScale.domain()[0],
);
} catch (err) {
return;
}
if (!toRgb) return;
const dataX = Math.round(this._xScale.invert(relX));
const dataY = Math.round(this._yScale.invert(relY));
let center = [dataX, dataY];
let xRange = [
Math.round(this._xScale.invert(relX - this.dataLensPadding)),
Math.round(this._xScale.invert(relX + this.dataLensPadding)),
];
let yRange = [
Math.round(this._yScale.invert(relY - this.dataLensPadding)),
Math.round(this._yScale.invert(relY + this.dataLensPadding)),
];
if (this.chromInfo) {
center = center.map((pos) => absToChr(pos, this.chromInfo).slice(0, 2));
xRange = xRange.map((pos) => absToChr(pos, this.chromInfo).slice(0, 2));
yRange = yRange.map((pos) => absToChr(pos, this.chromInfo).slice(0, 2));
}
this.onMouseMoveZoom({
trackId: this.id,
data,
absX,
absY,
relX,
relY,
dataX,
dataY,
orientation: '2d',
// Specific to 2D matrices
dataLens,
dim,
toRgb,
center,
xRange,
yRange,
isGenomicCoords: !!this.chromInfo,
});
}
scheduleRerender() {
this.backgroundTaskScheduler.enqueueTask(
this.handleRerender.bind(this),
null,
this.uuid,
);
}
handleRerender() {
this.rerender(this.options, true);
}
/**
* Get absolute (i.e., display) tile dimension and position.
*
* @param {Number} zoomLevel Current zoom level.
* @param {Array} tilePos Tile position.
* @return {Object} Object holding the absolute x, y, width, and height.
*/
getAbsTileDim(zoomLevel, tilePos, mirrored) {
const { tileX, tileY, tileWidth, tileHeight } =
this.getTilePosAndDimensions(zoomLevel, tilePos);
const dim = {};
dim.width = this._refXScale(tileX + tileWidth) - this._refXScale(tileX);
dim.height = this._refYScale(tileY + tileHeight) - this._refYScale(tileY);
if (mirrored) {
// this is a mirrored tile that represents the other half of a
// triangular matrix
dim.x = this._refXScale(tileY);
dim.y = this._refYScale(tileX);
} else {
dim.x = this._refXScale(tileX);
dim.y = this._refYScale(tileY);
}
return dim;
}
updateValueScale() {
let minValue = this.minValue();
let maxValue = this.maxValue();
// There might be only one value in the visible area. We extend the
// valuescale artificially, so that point is still displayed
const epsilon = 1e-6;
if (
minValue !== undefined &&
minValue !== null &&
maxValue !== undefined &&
maxValue !== null &&
Math.abs(minValue - maxValue) < epsilon
) {
// don't go to or below 0 in case there is a log scale
const offset = 1e-3;
minValue = Math.max(epsilon, minValue - offset);
maxValue += offset;
}
const [scaleType, valueScale] = getValueScale(
this.options?.heatmapValueScaling || 'log',
minValue,
this.medianVisibleValue,
maxValue,
'log',
);
this.valueScale = valueScale;
this.limitedValueScale = this.valueScale.copy();
if (
this.options &&
typeof this.options.scaleStartPercent !== 'undefined' &&
typeof this.options.scaleEndPercent !== 'undefined'
) {
this.limitedValueScale.domain([
this.valueScale.domain()[0] +
(this.valueScale.domain()[1] - this.valueScale.domain()[0]) *
this.options.scaleStartPercent,
this.valueScale.domain()[0] +
(this.valueScale.domain()[1] - this.valueScale.domain()[0]) *
this.options.scaleEndPercent,
]);
}
return [scaleType, valueScale];
}
rerender(options, force) {
super.rerender(options, force);
// We need to update the value scale prior to updating the colorbar
this.updateValueScale();
// if force is set, then we force a rerender even if the options
// haven't changed rerender will force a brush.move
const strOptions = JSON.stringify(options);
this.drawColorbar();
if (!force && strOptions === this.prevOptions) return;
this.prevOptions = strOptions;
this.options = options;
super.rerender(options, force);
// the normalization method may have changed
this.calculateVisibleTiles();
if (options?.colorRange) {
this.colorScale = colorDomainToRgbaArray(options.colorRange);
}
this.visibleAndFetchedTiles().forEach((tile) => this.renderTile(tile));
// hopefully draw isn't rerendering all the tiles
// this.drawColorbar();
if (this.hideMousePosition) {
this.hideMousePosition();
this.hideMousePosition = undefined;
}
if (this.options?.showMousePosition && !this.hideMousePosition) {
this.hideMousePosition = showMousePosition(
this,
this.is2d,
this.isShowGlobalMousePosition(),
);
}
}
drawLabel() {
if (this.options.labelPosition === this.options.colorbarPosition) {
this.labelXOffset = COLORBAR_AREA_WIDTH;
} else {
this.labelXOffset = 0;
}
super.drawLabel();
}
tileDataToCanvas(pixData) {
const canvas = document.createElement('canvas');
canvas.width = this.binsPerTile();
canvas.height = this.binsPerTile();
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'transparent';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const pix = new ImageData(pixData, canvas.width, canvas.height);
ctx.putImageData(pix, 0, 0);
return canvas;
}
exportData() {
if (this.tilesetInfo) {
// const currentResolution = tileProxy.calculateResolution(this.tilesetInfo,
// this.zoomLevel);
// const pixelsWidth = (this._xScale.domain()[1] - this._xScale.domain()[0])
// / currentResolution;
// const pixelsHeight = (this._yScale.domain()[1] - this._yScale.domain()[0])
// / currentResolution;
const data = this.getVisibleRectangleData(
0,
0,
this.dimensions[0],
this.dimensions[1],
);
const output = {
bounds: [this._xScale.domain(), this._yScale.domain()],
dimensions: data.shape,
data: ndarrayFlatten(data),
};
download('data.json', JSON.stringify(output));
}
}
/**
* Position sprite (the rendered tile)
*
* @param {Object} sprite PIXI sprite object.
* @param {Number} zoomLevel Current zoom level.
* @param {Array} tilePos X,Y position of tile.
* @param {Boolean} mirrored If `true` tile is mirrored.
*/
setSpriteProperties(sprite, zoomLevel, tilePos, mirrored) {
const dim = this.getAbsTileDim(zoomLevel, tilePos, mirrored);
sprite.width = dim.width;
sprite.height = dim.height;
sprite.x = dim.x;
sprite.y = dim.y;
if (mirrored && tilePos[0] !== tilePos[1]) {
// sprite.pivot = [this._refXScale()[1] / 2, this._refYScale()[1] / 2];
// I think PIXIv3 used a different method to set the pivot value
// because the code above no longer works as of v4
sprite.rotation = -Math.PI / 2;
sprite.scale.x = Math.abs(sprite.scale.x) * -1;
}
}
refXScale(_) {
super.refXScale(_);
this.draw();
}
refYScale(_) {
super.refYScale(_);
this.draw();
}
draw() {
super.draw();
// this.drawColorbar();
}
newBrushOptions(selection) {
const newOptions = JSON.parse(JSON.stringify(this.options));
const axisValueScale = this.valueScale
.copy()
.range([this.colorbarHeight, 0]);
const endDomain = axisValueScale.invert(selection[0]);
const startDomain = axisValueScale.invert(selection[1]);
// Fritz: I am disabling ESLint here twice because moving the slash onto the
// next line breaks my editors style template somehow.
const startPercent =
(startDomain - axisValueScale.domain()[0]) /
(axisValueScale.domain()[1] - axisValueScale.domain()[0]);
const endPercent =
(endDomain - axisValueScale.domain()[0]) /
(axisValueScale.domain()[1] - axisValueScale.domain()[0]);
newOptions.scaleStartPercent = startPercent.toFixed(SCALE_LIMIT_PRECISION);
newOptions.scaleEndPercent = endPercent.toFixed(SCALE_LIMIT_PRECISION);
return newOptions;
}
brushStart() {
this.brushing = true;
}
brushMoved(event) {
if (!event.selection) {
return;
}
const newOptions = this.newBrushOptions(event.selection);
const strOptions = JSON.stringify(newOptions);
this.gColorscaleBrush
.selectAll('.handle--custom')
.attr('y', (d) =>
d.type === 'n'
? event.selection[0]
: event.selection[1] - BRUSH_HEIGHT / 2,
);
if (strOptions === this.prevOptions) return;
this.prevOptions = strOptions;
// force a rerender because we've already set prevOptions
// to the new options
// this is necessary for when value scales are synced between
// tracks
this.rerender(newOptions, true);
this.onTrackOptionsChanged(newOptions);
if (this.isValueScaleLocked()) {
this.onValueScaleChanged();
}
}
brushEnd() {
// let newOptions = this.newBrushOptions(event.selection);
// this.rerender(newOptions);
// this.animate();
this.brushing = false;
}
setPosition(newPosition) {
super.setPosition(newPosition);
this.drawColorbar();
}
setDimensions(newDimensions) {
super.setDimensions(newDimensions);
this.drawColorbar();
}
removeColorbar() {
this.pColorbarArea.visible = false;
if (this.scaleBrush.on('.brush')) {
this.gColorscaleBrush.call(this.scaleBrush.move, null);
}
// turn off the color scale brush
this.gColorscaleBrush.on('.brush', null);
this.gColorscaleBrush.selectAll('rect').remove();
}
drawColorbar() {
this.pColorbar.clear();
// console.trace('draw colorbar');
if (
!this.options ||
!this.options.colorbarPosition ||
this.options.colorbarPosition === 'hidden'
) {
this.removeColorbar();
return;
}
this.pColorbarArea.visible = true;
if (!this.valueScale) {
return;
}
if (
Number.isNaN(+this.valueScale.domain()[0]) ||
Number.isNaN(+this.valueScale.domain()[1])
) {
return;
}
const colorbarAreaHeight = Math.min(
this.dimensions[1] / 2,
COLORBAR_MAX_HEIGHT,
);
this.colorbarHeight = colorbarAreaHeight - 2 * COLORBAR_MARGIN;
// no point in drawing the colorbar if it's not going to be visible
if (this.colorbarHeight < 0) {
// turn off the color scale brush
this.removeColorbar();
return;
}
if (this.valueScale.domain()[1] === this.valueScale.domain()[0]) {
// degenerate color bar
this.removeColorbar();
return;
}
const axisValueScale = this.valueScale
.copy()
.range([this.colorbarHeight, 0]);
// this.scaleBrush = brushY();
// this is to make the handles of the scale brush stick out away
// from the colorbar
if (
this.options.colorbarPosition === 'topLeft' ||
this.options.colorbarPosition === 'bottomLeft'
) {
this.scaleBrush.extent([
[BRUSH_MARGIN, 0],
[BRUSH_WIDTH, this.colorbarHeight],
]);
} else {
this.scaleBrush.extent([
[0, 0],
[BRUSH_WIDTH - BRUSH_MARGIN, this.colorbarHeight],
]);
}
if (this.options.colorbarPosition === 'topLeft') {
// draw the background for the colorbar
[this.pColorbarArea.x, this.pColorbarArea.y] = this.position;
this.pColorbar.y = COLORBAR_MARGIN;
this.axis.pAxis.y = COLORBAR_MARGIN;
this.axis.pAxis.x =
BRUSH_MARGIN + BRUSH_WIDTH + BRUSH_COLORBAR_GAP + COLORBAR_WIDTH;
this.pColorbar.x = BRUSH_MARGIN + BRUSH_WIDTH + BRUSH_COLORBAR_GAP;
this.gColorscaleBrush.attr(
'transform',
`translate(${this.pColorbarArea.x + BRUSH_MARGIN},${
this.pColorbarArea.y + this.pColorbar.y - 1
})`,
);
}
if (this.options.colorbarPosition === 'topRight') {
// draw the background for the colorbar
this.pColorbarArea.x =
this.position[0] + this.dimensions[0] - COLORBAR_AREA_WIDTH;
this.pColorbarArea.y = this.position[1];
this.pColorbar.y = COLORBAR_MARGIN;
this.axis.pAxis.y = COLORBAR_MARGIN;
// default to 'inside'
this.axis.pAxis.x = COLORBAR_LABELS_WIDTH + COLORBAR_MARGIN;
this.pColorbar.x = COLORBAR_LABELS_WIDTH + COLORBAR_MARGIN;
this.gColorscaleBrush.attr(
'transform',
`translate(${
this.pColorbarArea.x + this.pColorbar.x + COLORBAR_WIDTH + 2
},${this.pColorbarArea.y + this.pColorbar.y - 1})`,
);
}
if (this.options.colorbarPosition === 'bottomRight') {
this.pColorbarArea.x =
this.position[0] + this.dimensions[0] - COLORBAR_AREA_WIDTH;
this.pColorbarArea.y =
this.position[1] + this.dimensions[1] - colorbarAreaHeight;
this.pColorbar.y = COLORBAR_MARGIN;
this.axis.pAxis.y = COLORBAR_MARGIN;
// default to "inside"
this.axis.pAxis.x = COLORBAR_LABELS_WIDTH + COLORBAR_MARGIN;
this.pColorbar.x = COLORBAR_LABELS_WIDTH + COLORBAR_MARGIN;
this.gColorscaleBrush.attr(
'transform',
`translate(${
this.pColorbarArea.x +
this.pColorbar.x +
COLORBAR_WIDTH +
BRUSH_COLORBAR_GAP
},${this.pColorbarArea.y + this.pColorbar.y - 1})`,
);
}
if (this.options.colorbarPosition === 'bottomLeft') {
this.pColorbarArea.x = this.position[0];
this.pColorbarArea.y =
this.position[1] + this.dimensions[1] - colorbarAreaHeight;
this.pColorbar.y = COLORBAR_MARGIN;
this.axis.pAxis.y = COLORBAR_MARGIN;
// default to "inside"
this.axis.pAxis.x =
BRUSH_MARGIN + BRUSH_WIDTH + BRUSH_COLORBAR_GAP + COLORBAR_WIDTH;
this.pColorbar.x = BRUSH_MARGIN + BRUSH_WIDTH + BRUSH_COLORBAR_GAP;
this.gColorscaleBrush.attr(
'transform',
`translate(${this.pColorbarArea.x + 2},${
this.pColorbarArea.y + this.pColorbar.y - 1
})`,
);
}
this.pColorbarArea.clear();
this.pColorbarArea.beginFill(
colorToHex(this.options.colorbarBackgroundColor || 'white'),
+this.options.colorbarBackgroundOpacity >= 0
? +this.options.colorbarBackgroundOpacity
: 0.6,
);
this.pColorbarArea.drawRect(0, 0, COLORBAR_AREA_WIDTH, colorbarAreaHeight);
if (!this.options) {
this.options = { scaleStartPercent: 0, scaleEndPercent: 1 };
} else {
if (!this.options.scaleStartPercent) {
this.options.scaleStartPercent = 0;
}
if (!this.options.scaleEndPercent) {
this.options.scaleEndPercent = 1;
}
}
const domainWidth = axisValueScale.domain()[1] - axisValueScale.domain()[0];
const startBrush = axisValueScale(
this.options.scaleStartPercent * domainWidth + axisValueScale.domain()[0],
);
const endBrush = axisValueScale(
this.options.scaleEndPercent * domainWidth + axisValueScale.domain()[0],
);
// endBrush and startBrush are reversed because lower values come first
// only set if the user isn't brushing at the moment
if (!this.brushing) {
this.scaleBrush
.on('start', this.brushStart.bind(this))
.on('brush', this.brushMoved.bind(this))
.on('end', this.brushEnd.bind(this))
.handleSize(0);
this.gColorscaleBrush.on('.brush', null);
this.gColorscaleBrush.call(this.scaleBrush);
this.northHandle = this.gColorscaleBrush
.selectAll('.handle--custom')
.data([{ type: 'n' }, { type: 's' }])
.enter()
.append('rect')
.classed('handle--custom', true)
.attr('cursor', 'ns-resize')
.attr('width', BRUSH_WIDTH)
.attr('height', BRUSH_HEIGHT)
.style('fill', '#666')
.style('stroke', 'white');
if (this.flipText) {
this.northHandle.attr('cursor', 'ew-resize');
}
this.gColorscaleBrush.call(this.scaleBrush.move, [endBrush, startBrush]);
}
const posScale = scaleLinear()
.domain([0, 255])
.range([0, this.colorbarHeight]);
// draw a small rectangle for each color of the colorbar
for (let i = 0; i < this.colorbarHeight; i++) {
const value = this.limitedValueScale(axisValueScale.invert(i));
const rgbIdx = Math.max(0, Math.min(254, Math.floor(value)));
this.pColorbar.beginFill(
colorToHex(
`rgb(${this.colorScale[rgbIdx][0]},${this.colorScale[rgbIdx][1]},${this.colorScale[rgbIdx][2]})`,
),
);
// each rectangle in the colorbar will be one pixel high
this.pColorbar.drawRect(0, i, COLORBAR_WIDTH, 1);
}
// draw an axis on the right side of the colorbar
this.pAxis.position.x = COLORBAR_WIDTH;
this.pAxis.position.y = posScale(0);
if (
this.options.colorbarPosition === 'topLeft' ||
this.options.colorbarPosition === 'bottomLeft'
) {
this.axis.drawAxisRight(axisValueScale, this.colorbarHeight);
} else if (
this.options.colorbarPosition === 'topRight' ||
this.options.colorbarPosition === 'bottomRight'
) {
this.axis.drawAxisLeft(axisValueScale, this.colorbarHeight);
}
}
exportColorBarSVG() {
const gColorbarArea = document.createElement('g');
gColorbarArea.setAttribute('class', 'color-bar');
if (
!this.options.colorbarPosition ||
this.options.colorbarPosition === 'hidden'
) {
// if there's no visible colorbar, we don't need to export anything
return gColorbarArea;
}
// no value scale, no colorbar
if (!this.valueScale) return gColorbarArea;
gColorbarArea.setAttribute(
'transform',
`translate(${this.pColorbarArea.x}, ${this.pColorbarArea.y})`,
);
const rectColorbarArea = document.createElement('rect');
gColorbarArea.appendChild(rectColorbarArea);
const gColorbar = document.createElement('g');
gColorbarArea.appendChild(gColorbar);
gColorbar.setAttribute(
'transform',
`translate(${this.pColorbar.x}, ${this.pColorbar.y})`,
);
const colorbarAreaHeight = Math.min(
this.dimensions[1] / 2,
COLORBAR_MAX_HEIGHT,
);
this.colorbarHeight = colorbarAreaHeight - 2 * COLORBAR_MARGIN;
rectColorbarArea.setAttribute('x', 0);
rectColorbarArea.setAttribute('y', 0);
rectColorbarArea.setAttribute('width', COLORBAR_AREA_WIDTH);
rectColorbarArea.setAttribute('height', colorbarAreaHeight);
rectColorbarArea.setAttribute(
'style',
'fill: white; stroke-width: 0; opacity: 0.7',
);
const barsToDraw = 256;
const posScale = scaleLinear()
.domain([0, barsToDraw - 1])
.range([0, this.colorbarHeight]);
const colorHeight = this.colorbarHeight / barsToDraw;
for (let i = 0; i < barsToDraw; i++) {
const rectColor = document.createElement('rect');
gColorbar.appendChild(rectColor);
rectColor.setAttribute('x', 0);
rectColor.setAttribute('y', posScale(i));
rectColor.setAttribute('width', COLORBAR_WIDTH);
rectColor.setAttribute('height', colorHeight);
rectColor.setAttribute('class', 'color-rect');
const limitedIndex = Math.min(
this.colorScale.length - 1,
Math.max(
0,
Math.floor(this.limitedValueScale(this.valueScale.invert(i))),
),
);
const color = this.colorScale[limitedIndex];
if (color) {
rectColor.setAttribute(
'style',
`fill: rgb(${color[0]}, ${color[1]}, ${color[2]})`,
);
} else {
// when no tiles are loaded, color will be undefined and we don't want to crash
rectColor.setAttribute('style', 'fill: rgb(255,255,255,0)');
}
}
const gAxisHolder = document.createElement('g');
gColorbarArea.appendChild(gAxisHolder);
gAxisHolder.setAttribute(
'transform',
`translate(${this.axis.pAxis.position.x},${this.axis.pAxis.position.y})`,
);
let gAxis = null;
const axisValueScale = this.valueScale
.copy()
.range([this.colorbarHeight, 0]);
if (
this.options.colorbarPosition === 'topLeft' ||
this.options.colorbarPosition === 'bottomLeft'
) {
gAxis = this.axis.exportAxisRightSVG(axisValueScale, this.colorbarHeight);
} else if (
this.options.colorbarPosition === 'topRight' ||
this.options.colorbarPosition === 'bottomRight'
) {
gAxis = this.axis.exportAxisLeftSVG(axisValueScale, this.colorbarHeight);
}
gAxisHolder.appendChild(gAxis);
return gColorbarArea;
}
/**
* Set data lens size
*
* @param {Integer} newDataLensSize New data lens size. Needs to be an odd
* integer.
*/
setDataLensSize(newDataLensSize) {
this.dataLensPadding = Math.max(0, Math.floor((newDataLensSize - 1) / 2));
this.dataLensSize = this.dataLensPadding * 2 + 1;
}
binsPerTile() {
return this.tilesetInfo.bins_per_dimension || BINS_PER_TILE;
}
/**
* Get the data in the visible rectangle
*
* The parameter coordinates are in pixel coordinates
*
* @param {int} x: The upper left corner of the rectangle in pixel coordinates
* @param {int} y: The upper left corner of the rectangle in pixel coordinates
* @param {int} width: The width of the rectangle (pixels)
* @param {int} height: The height of the rectangle (pixels)
*
* @returns {Array} A numjs array containing the data in the visible region
*
*/
getVisibleRectangleData(x, y, width, height) {
let zoomLevel = this.calculateZoomLevel();
zoomLevel = this.tilesetInfo.max_zoom
? Math.min(this.tilesetInfo.max_zoom, zoomLevel)
: zoomLevel;
const calculatedWidth = calculateTileWidth(
this.tilesetInfo,
zoomLevel,
this.binsPerTile(),
);
// BP resolution of a tile's bin (i.e., numbe of base pairs per bin / pixel)
const tileRes = calculatedWidth / this.binsPerTile();
// the data domain of the currently visible region
const xDomain = [this._xScale.invert(x), this._xScale.invert(x + width)];
const yDomain = [this._yScale.invert(y), this._yScale.invert(y + height)];
// we need to limit the domain of the requested region
// to the bounds of the data
const limitedXDomain = [
Math.max(xDomain[0], this.tilesetInfo.min_pos[0]),
Math.min(xDomain[1], this.tilesetInfo.max_pos[0]),
];
const limitedYDomain = [
Math.max(yDomain[0], this.tilesetInfo.min_pos[1]),
Math.min(yDomain[1], this.tilesetInfo.max_pos[1]),
];
// the bounds of the currently visible region in bins
const leftXBin = Math.floor(limitedXDomain[0] / tileRes);
const leftYBin = Math.floor(limitedYDomain[0] / tileRes);
const binWidth = Math.max(
0,
Math.ceil((limitedXDomain[1] - limitedXDomain[0]) / tileRes),
);
const binHeight = Math.max(
0,
Math.ceil((limitedYDomain[1] - limitedYDomain[0]) / tileRes),
);
const out = ndarray(new Array(binHeight * binWidth).fill(Number.NaN), [
binHeight,
binWidth,
]);
// iterate through all the visible tiles
this.visibleAndFetchedTiles().forEach((tile) => {
const tilePos = tile.mirrored
? [tile.tileData.tilePos[1], tile.tileData.tilePos[0]]
: tile.tileData.tilePos;
// get the tile's position and width (in data coordinates)
// if it's mirrored then we have to switch the position indeces
const { tileX, tileY, tileWidth, tileHeight } =
this.getTilePosAndDimensions(
tile.tileData.zoomLevel,
tilePos,
this.binsPerTile(),
);
// calculate the tile's position in bins
const tileXStartBin = Math.floor(tileX / tileRes);
const tileXEndBin = Math.floor((tileX + tileWidth) / tileRes);
const tileYStartBin = Math.floor(tileY / tileRes);
const tileYEndBin = Math.floor((tileY + tileHeight) / tileRes);
// calculate which part of this tile is present in the current window
let tileSliceXStart = Math.max(leftXBin, tileXStartBin) - tileXStartBin;
let tileSliceYStart = Math.max(leftYBin, tileYStartBin) - tileYStartBin;
const tileSliceXEnd =
Math.min(leftXBin + binWidth, tileXEndBin) - tileXStartBin;
const tileSliceYEnd =
Math.min(leftYBin + binHeight, tileYEndBin) - tileYStartBin;
// where in the output array will the portion of this tile which is in the
// visible window be placed?
const tileXOffset = Math.max(tileXStartBin - leftXBin, 0);
const tileYOffset = Math.max(tileYStartBin - leftYBin, 0);
const tileSliceWidth = tileSliceXEnd - tileSliceXStart;
const tileSliceHeight = tileSliceYEnd - tileSliceYStart;
// the region is outside of this tile
if (tileSliceWidth < 0 || tileSliceHeight < 0) return;
if (tile.mirrored && tileSliceXStart > tileSliceYStart) {
const tmp = tileSliceXStart;
tileSliceXStart = tileSliceYStart;
tileSliceYStart = tmp;
}
ndarrayAssign(
out
.hi(tileYOffset + tileSliceHeight, tileXOffset + tileSliceWidth)
.lo(tileYOffset, tileXOffset),
tile.dataArray
.hi(
tileSliceYStart + tileSliceHeight,
tileSliceXStart + tileSliceWidth,
)
.lo(tileSliceYStart, tileSliceXStart),
);
});
return out;
}
/**
* Convert the raw tile data to a rendered array of values which can be represented as a sprite.
*
* @param tile: The data structure containing all the tile information. Relevant to
* this function are tile.tileData = \{'dense': [...], ...\}
* and tile.graphics
*/
initTile(tile) {
super.initTile(tile);
// prepare the data for fast retrieval in getVisibleRectangleData
if (tile.tileData.dense.length === this.binsPerTile() ** 2) {
tile.dataArray = ndarray(Array.from(tile.tileData.dense), [
this.binsPerTile(),
this.binsPerTile(),
]);
// Recompute DenseDataExtrema for diagonal tiles which have been mirrored
if (
this.continuousScaling &&
tile.tileData.tilePos[0] === tile.tileData.tilePos[1] &&
tile.mirrored
) {
tile.tileData.denseDataExtrema.mirrorPrecomputedExtrema();
super.initTile(tile);
}
}
// no data present
if (this.scale.minValue === null || this.scale.maxValue === null) {
return;
}
this.renderTile(tile);
}
// /**
// * Draw a border around tiles
// *
// * @param {Array} pixData Pixel data to be adjusted
// */
// addBorder(pixData) {
// for (let i = 0; i < 256; i++) {
// if (i === 0) {
// const prefix = i * 256 * 4;
// for (let j = 0; j < 255; j++) {
// pixData[prefix + (j * 4)] = 0;
// pixData[prefix + (j * 4) + 1] = 0;
// pixData[prefix + (j * 4) + 2] = 255;
// pixData[prefix + (j * 4) + 3] = 255;
// }
// }
// pixData[(i * 256 * 4)] = 0;
// pixData[(i * 256 * 4) + 1] = 0;
// pixData[(i * 256 * 4) + 2] = 255;
// pixData[(i * 256 * 4) + 3] = 255;
// }
// }
//
updateTile(tile) {
if (
tile.scale &&
this.scale &&
this.scale.minValue === tile.scale.minValue &&
this.scale.maxValue === tile.scale.maxValue
) {
// already rendered properly, no need to rerender
} else {
// not rendered using the current scale, so we need to rerender
this.renderTile(tile);
this.drawColorbar();
}
}
destroyTile(tile) {
// sprite have to be explicitly destroyed in order to
// free the texture cache
tile.sprite.destroy(true);
tile.canvas = null;
tile.sprite = null;
tile.texture = null;
// this is a handy method for checking what's in the texture
// cache
// console.log('destroy', PIXI.utils.BaseTextureCache);
}
pixDataFunction(tile, pixData) {
// the tileData has been converted to pixData by the worker script and
// needs to be loaded as a sprite
if (pixData) {
const { graphics } = tile;
const canvas = this.tileDataToCanvas(pixData.pixData);
if (tile.sprite) {
// if this tile has already been rendered with a sprite, we
// have to destroy it before creating a new one
tile.sprite.destroy(true);
}
const texture =
GLOBALS.PIXI.VERSION[0] === '4'
? GLOBALS.PIXI.Texture.fromCanvas(
canvas,
GLOBALS.PIXI.SCALE_MODES.NEAREST,
)
: GLOBALS.PIXI.Texture.from(canvas, {
scaleMode: GLOBALS.PIXI.SCALE_MODES.NEAREST,
});
const sprite = new GLOBALS.PIXI.Sprite(texture);
tile.sprite = sprite;
tile.texture = texture;
// store the pixData so that we can export it
tile.canvas = canvas;
this.setSpriteProperties(
tile.sprite,
tile.tileData.zoomLevel,
tile.tileData.tilePos,
tile.mirrored,
);
graphics.removeChildren();
graphics.addChild(tile.sprite);
}
this.renderingTiles.delete(tile.tileId);
}
/**
* Render / draw a tile.
*
* @param {Object} tile Tile data to be rendered.
*/
renderTile(tile) {
const [scaleType] = this.updateValueScale();
const pseudocount = 0;
this.renderingTiles.add(tile.tileId);
if (this.tilesetInfo.tile_size) {
if (tile.tileData.dense?.length < this.tilesetInfo.tile_size) {
// we haven't gotten a full tile from the server so we want to pad
// it with nan values
const newArray = new Float32Array(this.tilesetInfo.tile_size);
newArray.fill(Number.NaN);
newArray.set(tile.tileData.dense);
tile.tileData.dense = newArray;
}
}
tileDataToPixData(
tile,
scaleType,
this.limitedValueScale.domain(),
pseudocount, // used as a pseudocount to prevent taking the log of 0
this.colorScale,
(pixData) => this.pixDataFunction(tile, pixData),
this.mirrorTiles() &&
!tile.mirrored &&
tile.tileData.tilePos[0] === tile.tileData.tilePos[1],
this.options.extent === 'upper-right' &&
tile.tileData.tilePos[0] === tile.tileData.tilePos[1],
this.options.zeroValueColor
? colorToRgba(this.options.zeroValueColor)
: undefined,
{
selectedRows: this.options.selectRows,
selectedRowsAggregationMode: this.options.selectRowsAggregationMode,
selectedRowsAggregationWithRelativeHeight:
this.options.selectRowsAggregationWithRelativeHeight,
selectedRowsAggregationMethod: this.options.selectRowsAggregationMethod,
},
);
}
/**
* Remove this track from the view
*/
remove() {
this.gMain.remove();
this.gMain = null;
super.remove();
}
refScalesChanged(refXScale, refYScale) {
super.refScalesChanged(refXScale, refYScale);
objVals(this.fetchedTiles)
.filter((tile) => tile.sprite)
.forEach((tile) =>
this.setSpriteProperties(
tile.sprite,
tile.tileData.zoomLevel,
tile.tileData.tilePos,
tile.mirrored,
),
);
}
/**
* Bypass this track's exportSVG function
*/
superSVG() {
return super.exportSVG();
}
exportSVG() {
let track = null;
let base = null;
if (super.exportSVG) {
[base, track] = super.exportSVG();
} else {
base = document.createElement('g');
track = base;
}
const output = document.createElement('g');
track.appendChild(output);
output.setAttribute(
'transform',
`translate(${this.pMain.position.x},${this.pMain.position.y}) scale(${this.pMain.scale.x},${this.pMain.scale.y})`,
);
for (const tile of this.visibleAndFetchedTiles()) {
const rotation = (tile.sprite.rotation * 180) / Math.PI;
const g = document.createElement('g');
g.setAttribute(
'transform',
`translate(${tile.sprite.x},${tile.sprite.y}) rotate(${rotation}) scale(${tile.sprite.scale.x},${tile.sprite.scale.y})`,
);
const image = document.createElement('image');
image.setAttributeNS(
'http://www.w3.org/1999/xlink',
'xlink:href',
tile.canvas.toDataURL(),
);
image.setAttribute('width', tile.canvas.width);
image.setAttribute('height', tile.canvas.height);
image.setAttribute('style', 'image-rendering: pixelated');
g.appendChild(image);
output.appendChild(g);
}
const gColorbar = this.exportColorBarSVG();
track.appendChild(gColorbar);
return [base, base];
}
// This function gets the indices of the visible part of the upper left tile.
// The indices are 'rounded' to the grid used by the DenseDataExrema module.
// It is used to determine if we should check for a new value scale in
// the case of continuous scaling
getVisiblePartOfUppLeftTile() {
const tilePositions = this.visibleAndFetchedTiles().map((tile) => {
const tilePos = tile.mirrored
? [tile.tileData.tilePos[1], tile.tileData.tilePos[0]]
: tile.tileData.tilePos;
return [tilePos[0], tilePos[1], tile.tileId];
});
if (tilePositions.length === 0) return null;
let minTilePosition = tilePositions[0];
for (let i = 0; i < tilePositions.length; i++) {
const curPos = tilePositions[i];
if (curPos[0] < minTilePosition[0] || curPos[1] < minTilePosition[1]) {
minTilePosition = curPos;
}
}
const numSubsets = Math.min(
NUM_PRECOMP_SUBSETS_PER_2D_TTILE,
this.binsPerTile(),
);
const subsetSize = this.binsPerTile() / numSubsets;
const upperLeftTile = this.visibleAndFetchedTiles().filter(
(tile) => tile.tileId === minTilePosition[2],
)[0];
const upperLeftTileInd = this.getIndicesOfVisibleDataInTile(upperLeftTile);
const startX = upperLeftTileInd[0];
const startY = upperLeftTileInd[1];
// round to nearest grid point as used in the DenseDataExtrema Module
const startXadjusted = startX - (startX % subsetSize);
const startYadjusted = startY - (startY % subsetSize);
return [upperLeftTile.tileId, startXadjusted, startYadjusted];
}
getIndicesOfVisibleDataInTile(tile) {
const visibleX = this._xScale.range();
const visibleY = this._yScale.range();
const tilePos = tile.mirrored
? [tile.tileData.tilePos[1], tile.tileData.tilePos[0]]
: tile.tileData.tilePos;
const { tileX, tileY, tileWidth, tileHeight } =
this.getTilePosAndDimensions(
tile.tileData.zoomLevel,
tilePos,
this.binsPerTile(),
);
const tileXScale = scaleLinear()
.domain([0, this.binsPerTile()])
.range([tileX, tileX + tileWidth]);
const startX = Math.max(
0,
Math.round(tileXScale.invert(this._xScale.invert(visibleX[0]))) - 1,
);
const endX = Math.min(
this.binsPerTile(),
Math.round(tileXScale.invert(this._xScale.invert(visibleX[1]))),
);
const tileYScale = scaleLinear()
.domain([0, this.binsPerTile()])
.range([tileY, tileY + tileHeight]);
const startY = Math.max(
0,
Math.round(tileYScale.invert(this._yScale.invert(visibleY[0]))) - 1,
);
const endY = Math.min(
this.binsPerTile(),
Math.round(tileYScale.invert(this._yScale.invert(visibleY[1]))),
);
const result =
tile.mirrored && tilePos[0] !== tilePos[1]
? [startY, startX, endY, endX]
: [startX, startY, endX, endY];
return result;
}
minVisibleValue(ignoreFixedScale = false) {
const minimumsPerTile = this.visibleAndFetchedTiles().map((tile) => {
if (tile.tileData.denseDataExtrema === undefined) {
return null;
}
const ind = this.getIndicesOfVisibleDataInTile(tile);
return tile.tileData.denseDataExtrema.getMinNonZeroInSubset(ind);
});
if (minimumsPerTile.length === 0 && this.valueScaleMax === null) {
return null;
}
const min = Math.min.apply(null, minimumsPerTile);
// If there is no data or no denseDataExtrema, go to parent method
if (min === Number.MAX_SAFE_INTEGER) {
return super.minVisibleValue(ignoreFixedScale);
}
if (ignoreFixedScale) return min;
return this.valueScaleMin !== null ? this.valueScaleMin : min;
}
maxVisibleValue(ignoreFixedScale = false) {
const maximumsPerTile = this.visibleAndFetchedTiles().map((tile) => {
if (tile.tileData.denseDataExtrema === undefined) {
return null;
}
const ind = this.getIndicesOfVisibleDataInTile(tile);
return tile.tileData.denseDataExtrema.getMaxNonZeroInSubset(ind);
});
if (maximumsPerTile.length === 0 && this.valueScaleMax === null) {
return null;
}
const max = Math.max.apply(null, maximumsPerTile);
// If there is no data or no deseDataExtrema, go to parent method
if (max === Number.MIN_SAFE_INTEGER) {
return super.maxVisibleValue(ignoreFixedScale);
}
if (ignoreFixedScale) return max;
return this.valueScaleMax !== null ? this.valueScaleMax : max;
}
zoomed(newXScale, newYScale, k, tx, ty) {
if (this.brushing) {
return;
}
super.zoomed(newXScale, newYScale);
this.pMain.position.x = tx; // translateX;
this.pMain.position.y = ty; // translateY;
this.pMain.scale.x = k; // scaleX;
this.pMain.scale.y = k; // scaleY;
const isValueScaleLocked = this.isValueScaleLocked();
if (
this.continuousScaling &&
this.minValue() !== undefined &&
this.maxValue() !== undefined
) {
// Get the indices of the visible part of the upper left tile.
// Helps to determine if we zoomed far enough to justify a min/max computation
const indUpperLeftTile = JSON.stringify(
this.getVisiblePartOfUppLeftTile(),
);
if (
this.valueScaleMin === null &&
this.valueScaleMax === null &&
!isValueScaleLocked &&
// syncs the recomputation with the grid used in the DenseDataExtrema module
indUpperLeftTile !== this.prevIndUpperLeftTile
) {
const newMin = this.minVisibleValue();
const newMax = this.maxVisibleValue();
const epsilon = 1e-6;
if (
newMin !== null && // can happen if tiles haven't loaded
newMax !== null &&
(Math.abs(this.minValue() - newMin) > epsilon ||
Math.abs(this.maxValue() - newMax) > epsilon)
) {
this.minValue(newMin);
this.maxValue(newMax);
this.scheduleRerender();
}
this.prevIndUpperLeftTile = indUpperLeftTile;
}
if (isValueScaleLocked) {
this.onValueScaleChanged();
}
}
this.mouseMoveZoomHandler();
}
/**
* Helper method for adding a tile ID in place. Used by `tilesToId()`.
*
* @param {Array} tiles Array tile ID should be added to.
* @param {Integer} zoomLevel Zoom level.
* @param {Integer} row Column ID, i.e., y.
* @param {Integer} column Column ID, i.e., x.
* @param {Objwect} dataTransform ??
* @param {Boolean} mirrored If `true` tile is mirrored.
*/
addTileId(tiles, zoomLevel, row, column, dataTransform, mirrored = false) {
const newTile = [zoomLevel, row, column];
newTile.mirrored = mirrored;
newTile.dataTransform = dataTransform;
tiles.push(newTile);
}
/**
* Convert tile positions to tile IDs
*
* @param {Array} xTiles X positions of tiles
* @param {Array} yTiles Y positions of tiles
* @param {Array} zoomLevel Current zoom level
* @return {Array} List of tile IDs
*/
tilesToId(xTiles, yTiles, zoomLevel) {
const rows = xTiles;
const cols = yTiles;
const dataTransform = this.options?.dataTransform || 'default';
// if we're mirroring tiles, then we only need tiles along the diagonal
const tiles = [];
// calculate the ids of the tiles that should be visible
for (let i = 0; i < rows.length; i++) {
for (let j = 0; j < cols.length; j++) {
if (this.mirrorTiles()) {
if (rows[i] >= cols[j]) {
if (this.options.extent !== 'lower-left') {
// if we're in the upper triangular part of the matrix, then we need
// to load a mirrored tile
this.addTileId(
tiles,
zoomLevel,
cols[j],
rows[i],
dataTransform,
true,
);
}
} else if (this.options.extent !== 'upper-right') {
// otherwise, load an original tile
this.addTileId(tiles, zoomLevel, rows[i], cols[j], dataTransform);
}
if (rows[i] === cols[j] && this.options.extent === 'lower-left') {
// on the diagonal, load original tiles
this.addTileId(tiles, zoomLevel, rows[i], cols[j], dataTransform);
}
} else {
this.addTileId(tiles, zoomLevel, rows[i], cols[j], dataTransform);
}
}
}
return tiles;
}
calculateVisibleTiles() {
// if we don't know anything about this dataset, no point
// in trying to get tiles
if (!this.tilesetInfo) {
return;
}
this.zoomLevel = this.calculateZoomLevel();
// this.zoomLevel = 0;
if (this.tilesetInfo.resolutions) {
const sortedResolutions = this.tilesetInfo.resolutions
.map((x) => +x)
.sort((a, b) => b - a);
this.xTiles = calculateTilesFromResolution(
sortedResolutions[this.zoomLevel],
this._xScale,
this.tilesetInfo.min_pos[0],
this.tilesetInfo.max_pos[0],
);
this.yTiles = calculateTilesFromResolution(
sortedResolutions[this.zoomLevel],
this._yScale,
this.tilesetInfo.min_pos[0],
this.tilesetInfo.max_pos[0],
);
} else {
this.xTiles = calculateTiles(
this.zoomLevel,
this._xScale,
this.tilesetInfo.min_pos[0],
this.tilesetInfo.max_pos[0],
this.tilesetInfo.max_zoom,
this.tilesetInfo.max_width,
);
this.yTiles = calculateTiles(
this.zoomLevel,
this._yScale,
this.options.reverseYAxis
? -this.tilesetInfo.max_pos[1]
: this.tilesetInfo.min_pos[1],
this.options.reverseYAxis
? -this.tilesetInfo.min_pos[1]
: this.tilesetInfo.max_pos[1],
this.tilesetInfo.max_zoom,
this.tilesetInfo.max_width1 || this.tilesetInfo.max_width,
);
}
this.setVisibleTiles(
this.tilesToId(this.xTiles, this.yTiles, this.zoomLevel),
);
}
mirrorTiles() {
return !(
this.tilesetInfo.mirror_tiles &&
(this.tilesetInfo.mirror_tiles === false ||
this.tilesetInfo.mirror_tiles === 'false')
);
}
contextMenuItems(trackX, trackY) {
/* Get a list of context menu items to display and the actions
to take */
// This should return items like this:
// return [
// {
// label: 'Change background color to black',
// onClick: (evt, onTrackOptionsChanged) => {
// // The onTrackOptionsChanged handler will handle any changes
// // to the track's options that are triggered in this event.
// // The only thing that needs to be passed is the new option being
// // passed
// onTrackOptionsChanged({ backgroundColor: 'black' });
// },
// },
// ];
return [];
}
getMouseOverHtml(trackX, trackY) {
if (!this.options || !this.options.showTooltip) {
return '';
}
if (!this.tilesetInfo) {
return '';
}
const currentResolution = calculateResolution(
this.tilesetInfo,
this.zoomLevel,
);
const maxWidth = Math.max(
this.tilesetInfo.max_pos[1] - this.tilesetInfo.min_pos[1],
this.tilesetInfo.max_pos[0] - this.tilesetInfo.min_pos[0],
);
const formatResolution = Math.ceil(