higlass
Version:
HiGlass Hi-C / genomic / large data viewer
831 lines (697 loc) • 25.5 kB
JavaScript
import { formatPrefix, precisionPrefix } from 'd3-format';
import slugid from 'slugid';
import Track from './Track';
import colorToHex from './utils/color-to-hex';
// Configs
import GLOBALS from './configs/globals';
import {
isLegacyTilesetInfo,
isResolutionsTilesetInfo,
} from './utils/type-guards';
/**
* Format a resolution relative to the highest possible resolution.
*
* The highest possible resolution determines the granularity of the
* formatting (e.g. 20K vs 20000)
* @param {number} resolution The resolution to format (e.g. 30000)
* @param {number} maxResolutionSize The maximum possible resolution (e.g. 1000)
*
* @returns {string} A formatted resolution string (e.g. "30K")
*/
function formatResolutionText(resolution, maxResolutionSize) {
const pp = precisionPrefix(maxResolutionSize, resolution);
const f = formatPrefix(`.${pp}`, resolution);
const formattedResolution = f(resolution);
return formattedResolution;
}
/**
* Get a text description of a resolution based on a zoom level
* and a list of resolutions
*
* @param {Array<number>} resolutions: A list of resolutions (e.g. [1000,2000,3000])
* @param {number} zoomLevel: The current zoom level (e.g. 4)
*
* @returns {string} A formatted string representation of the zoom level (e.g. "30K")
*/
function getResolutionBasedResolutionText(resolutions, zoomLevel) {
const sortedResolutions = resolutions.map((x) => +x).sort((a, b) => b - a);
const resolution = sortedResolutions[zoomLevel];
const maxResolutionSize = sortedResolutions[sortedResolutions.length - 1];
return formatResolutionText(resolution, maxResolutionSize);
}
/**
* Get a text description of the resolution based on the zoom level
* max width of the dataset, the bins per dimension and the maximum zoom.
*
* @param {number} zoomLevel - The current zoomLevel (e.g. 0)
* @param {number} maxWidth - The max width (e.g. 2 ** maxZoom * highestResolution * binsPerDimension)
* @param {number} binsPerDimension - The number of bins per tile dimension (e.g. 256)
* @param {number} maxZoom - The maximum zoom level for this tileset
*
* @returns {string} A formatted string representation of the zoom level (e.g. "30K")
*/
function getWidthBasedResolutionText(
zoomLevel,
maxWidth,
binsPerDimension,
maxZoom,
) {
const resolution = maxWidth / (2 ** zoomLevel * binsPerDimension);
// we can't display a NaN resolution
if (!Number.isNaN(resolution)) {
// what is the maximum possible resolution?
// this will determine how we format the lower resolutions
const maxResolutionSize = maxWidth / (2 ** maxZoom * binsPerDimension);
const pp = precisionPrefix(maxResolutionSize, resolution);
const f = formatPrefix(`.${pp}`, resolution);
const formattedResolution = f(resolution);
return formattedResolution;
}
console.warn('NaN resolution, screen is probably too small.');
return '';
}
/**
* @typedef PixiTrackOptions
* @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.
* If either labelPosition or labelText are false, no label will be drawn.
* @property {number=} trackBorderWidth
* @property {string=} trackBorderColor
* @property {string=} backgroundColor
* @property {string=} labelColor
* @property {string=} lineStrokeColor
* @property {string=} barFillColor
* @property {string=} name
* @property {number=} labelTextOpacity
* @property {string=} labelBackgroundColor
* @property {number=} labelLeftMargin
* @property {number=} labelRightMargin
* @property {number=} labelTopMargin
* @property {number=} labelBottomMargin
* @property {number=} labelBackgroundOpacity
* @property {boolean=} labelShowAssembly
* @property {boolean=} labelShowResolution
* @property {string=} dataTransform
*/
/**
* @typedef {import('./Track').ExtendedTrackContext<{ scene: import('pixi.js').Container}>} PixiTrackContext
*/
/**
* @template T
* @typedef {T & PixiTrackContext} ExtendedPixiContext
*/
/**
* @template T
* @typedef {T & PixiTrackOptions} ExtendedPixiOptions
*/
/**
* @template {ExtendedPixiOptions<{[key: string]: any}>} Options
* @extends {Track<Options>} */
class PixiTrack extends Track {
/**
* @param {PixiTrackContext} context - Includes the PIXI.js scene to draw to.
* @param {Options} options - The options for this track.
*/
constructor(context, options) {
super(context, options);
const { scene } = context;
// the PIXI drawing areas
// pMain will have transforms applied to it as users scroll to and fro
/** @type {import('pixi.js').Container} */
this.scene = scene;
// this option is used to temporarily prevent drawing so that
// updates can be batched (e.g. zoomed and options changed)
/** @type {boolean} */
this.delayDrawing = false;
/** @type {import('pixi.js').Graphics} */
this.pBase = new GLOBALS.PIXI.Graphics();
/** @type {import('pixi.js').Graphics} */
this.pMasked = new GLOBALS.PIXI.Graphics();
/** @type {import('pixi.js').Graphics} */
this.pMask = new GLOBALS.PIXI.Graphics();
/** @type {import('pixi.js').Graphics} */
this.pMain = new GLOBALS.PIXI.Graphics();
// for drawing the track label (often its name)
/** @type {import('pixi.js').Graphics} */
this.pBorder = new GLOBALS.PIXI.Graphics();
/** @type {import('pixi.js').Graphics} */
this.pBackground = new GLOBALS.PIXI.Graphics();
/** @type {import('pixi.js').Graphics} */
this.pForeground = new GLOBALS.PIXI.Graphics();
/** @type {import('pixi.js').Graphics} */
this.pLabel = new GLOBALS.PIXI.Graphics();
/** @type {import('pixi.js').Graphics} */
this.pMobile = new GLOBALS.PIXI.Graphics();
/** @type {import('pixi.js').Graphics} */
this.pAxis = new GLOBALS.PIXI.Graphics();
// for drawing information on mouseover events
/** @type {import('pixi.js').Graphics} */
this.pMouseOver = new GLOBALS.PIXI.Graphics();
this.scene.addChild(this.pBase);
this.pBase.addChild(this.pMasked);
this.pMasked.addChild(this.pBackground);
this.pMasked.addChild(this.pMain);
this.pMasked.addChild(this.pMask);
this.pMasked.addChild(this.pMobile);
this.pMasked.addChild(this.pBorder);
this.pMasked.addChild(this.pLabel);
this.pMasked.addChild(this.pForeground);
this.pMasked.addChild(this.pMouseOver);
this.pBase.addChild(this.pAxis);
this.pMasked.mask = this.pMask;
/** @type {string} */
this.prevOptions = '';
// pMobile will be a graphics object that is moved around
// tracks that wish to use it will replace this.pMain with it
/** @type {PixiTrackOptions} */
this.options = Object.assign(this.options, options);
/** @type {string} */
const labelTextText = this.getName();
/** @type {string} */
this.labelTextFontFamily = 'Arial';
/** @type {number} */
this.labelTextFontSize = 12;
/**
* Used to avoid label/colormap clashes
* @type {number}
*/
this.labelXOffset = 0;
/** @type {import('pixi.js').Text} */
this.labelText = new GLOBALS.PIXI.Text(labelTextText, {
fontSize: `${this.labelTextFontSize}px`,
fontFamily: this.labelTextFontFamily,
fill: 'black',
});
this.pLabel.addChild(this.labelText);
/** @type {import('pixi.js').Text} */
this.errorText = new GLOBALS.PIXI.Text('', {
fontSize: '12px',
fontFamily: 'Arial',
fill: 'red',
});
this.errorText.anchor.x = 0.5;
this.errorText.anchor.y = 0.5;
this.pLabel.addChild(this.errorText);
/** @type {boolean} */
this.flipText = false;
/** @type {import('./types').TilesetInfo | undefined} */
this.tilesetInfo = undefined;
/** @type {{ [key: string]: string }} */
this.errorTexts = {};
}
setLabelText() {
// will be drawn in draw() anyway
}
/** @param {[number, number]} newPosition */
setPosition(newPosition) {
this.position = newPosition;
this.drawBorder();
this.drawLabel();
this.drawBackground();
this.setMask(this.position, this.dimensions);
this.setForeground();
}
/** @param {[number, number]} newDimensions */
setDimensions(newDimensions) {
super.setDimensions(newDimensions);
this.drawBorder();
this.drawLabel();
this.drawBackground();
this.setMask(this.position, this.dimensions);
this.setForeground();
}
/**
* @param {[number, number]} position
* @param {[number, number]} dimensions
*/
setMask(position, dimensions) {
this.pMask.clear();
this.pMask.beginFill();
this.pMask.drawRect(position[0], position[1], dimensions[0], dimensions[1]);
this.pMask.endFill();
}
setForeground() {
this.pForeground.position.y = this.position[1];
this.pForeground.position.x = this.position[0];
}
/**
* We're going to destroy this object, so we need to detach its
* graphics from the scene
*/
remove() {
// the entire PIXI stage was probably removed
this.pBase.clear();
this.scene.removeChild(this.pBase);
}
/**
* Draw a border around each track.
*/
drawBorder() {
const graphics = this.pBorder;
graphics.clear();
// don't display the track label
if (!this.options || !this.options.trackBorderWidth) return;
const stroke = colorToHex(
this.options.trackBorderColor ? this.options.trackBorderColor : 'white',
);
graphics.lineStyle(this.options.trackBorderWidth, stroke);
graphics.drawRect(
this.position[0],
this.position[1],
this.dimensions[0],
this.dimensions[1],
);
}
/** Set an error for this track.
*
* The error can be associated with a source so that multiple
* components within the track can set their own independent errors
* that will be displayed to the user without overlapping.
*
* @param {string} error The error text
* @param {string} source The source of the error
*/
setError(error, source) {
this.errorTexts[source] = error;
this.drawError();
}
drawError() {
this.errorText.x = this.position[0] + this.dimensions[0] / 2;
this.errorText.y = this.position[1] + this.dimensions[1] / 2;
// Collect all the error texts, filter out the ones that are empty
// and put the non-empty ones separate lines
const errorTextText = Object.values(this.errorTexts)
.filter((x) => x?.length)
.reduce((acc, x) => (acc ? `${acc}\n${x}` : x), '');
this.errorText.text = errorTextText;
this.errorText.alpha = 0.8;
if (errorTextText?.length) {
// draw a red border around the track to bring attention to its
// error
const graphics = this.pBorder;
graphics.clear();
graphics.lineStyle(1, colorToHex('red'));
graphics.drawRect(
this.position[0],
this.position[1],
this.dimensions[0],
this.dimensions[1],
);
} else {
this.pBorder.clear();
}
}
drawBackground() {
const graphics = this.pBackground;
graphics.clear();
if (!this.options || !this.options.backgroundColor) {
return;
}
let opacity = 1;
let color = this.options.backgroundColor;
if (this.options.backgroundColor === 'transparent') {
opacity = 0;
color = 'white';
}
const hexColor = colorToHex(color);
graphics.beginFill(hexColor, opacity);
graphics.drawRect(
this.position[0],
this.position[1],
this.dimensions[0],
this.dimensions[1],
);
}
/**
* Determine the label color based on the number of options.
*
* @return {string} The color to use for the label.
*/
getLabelColor() {
if (
this.options.labelColor &&
this.options.labelColor !== '[glyph-color]'
) {
return this.options.labelColor;
}
return this.options.lineStrokeColor || this.options.barFillColor || 'black';
}
getName() {
return this.options.name ? this.options.name : this.tilesetInfo?.name || '';
}
drawLabel() {
if (!this.labelText) return;
const graphics = this.pLabel;
graphics.clear();
// TODO(Trevor): I don't think this can ever be true. Options are always defined,
// and options.labelPosition can't be defined if this.options is undefined.
if (
!this.options ||
!this.options.labelPosition ||
this.options.labelPosition === 'hidden'
) {
// don't display the track label
this.labelText.alpha = 0;
return;
}
const { labelBackgroundColor = 'white', labelBackgroundOpacity = 0.5 } =
this.options;
graphics.beginFill(
colorToHex(labelBackgroundColor),
+labelBackgroundOpacity,
);
const fontColor = colorToHex(this.getLabelColor());
const labelBackgroundMargin = 2;
// we can't draw a label if there's no space
if (this.dimensions[0] < 0) {
return;
}
let labelTextText =
this.options.labelShowAssembly &&
this.tilesetInfo &&
this.tilesetInfo.coordSystem
? `${this.tilesetInfo.coordSystem} | `
: '';
labelTextText += this.getName();
if (
this.options.labelShowResolution &&
isLegacyTilesetInfo(this.tilesetInfo) &&
this.tilesetInfo.bins_per_dimension
) {
const formattedResolution = getWidthBasedResolutionText(
this.calculateZoomLevel(),
this.tilesetInfo.max_width,
this.tilesetInfo.bins_per_dimension,
this.tilesetInfo.max_zoom,
);
labelTextText += `\n[Current data resolution: ${formattedResolution}]`;
} else if (
this.options.labelShowResolution &&
isResolutionsTilesetInfo(this.tilesetInfo)
) {
const formattedResolution = getResolutionBasedResolutionText(
this.tilesetInfo.resolutions,
this.calculateZoomLevel(),
);
labelTextText += `\n[Current data resolution: ${formattedResolution}]`;
}
if (this.options?.dataTransform) {
let chosenTransform = null;
if (this.tilesetInfo?.transforms) {
for (const transform of this.tilesetInfo.transforms) {
if (transform.value === this.options.dataTransform) {
chosenTransform = transform;
}
}
}
if (chosenTransform) {
labelTextText += `\n[Transform: ${chosenTransform.name}]`;
} else if (this.options.dataTransform === 'None') {
labelTextText += '\n[Transform: None ]';
} else {
labelTextText += '\n[Transform: Default ]';
}
}
this.labelText.text = labelTextText;
this.labelText.style = {
fontSize: `${this.labelTextFontSize}px`,
fontFamily: this.labelTextFontFamily,
fill: fontColor,
};
this.labelText.alpha =
typeof this.options.labelTextOpacity !== 'undefined'
? this.options.labelTextOpacity
: 1;
this.labelText.visible = true;
if (this.flipText) {
this.labelText.scale.x = -1;
}
const {
labelLeftMargin = 0,
labelRightMargin = 0,
labelTopMargin = 0,
labelBottomMargin = 0,
} = this.options;
if (this.options.labelPosition === 'topLeft') {
this.labelText.x = this.position[0] + labelLeftMargin + this.labelXOffset;
this.labelText.y = this.position[1] + labelTopMargin;
this.labelText.anchor.x = 0.5;
this.labelText.anchor.y = 0;
this.labelText.x += this.labelText.width / 2;
graphics.drawRect(
this.position[0] + labelLeftMargin + this.labelXOffset,
this.position[1] + labelTopMargin,
this.labelText.width + labelBackgroundMargin,
this.labelText.height + labelBackgroundMargin,
);
} else if (
(this.options.labelPosition === 'bottomLeft' && !this.flipText) ||
(this.options.labelPosition === 'topRight' && this.flipText)
) {
this.labelText.x = this.position[0] + (labelLeftMargin || labelTopMargin);
this.labelText.y =
this.position[1] +
this.dimensions[1] -
(labelBottomMargin || labelRightMargin);
this.labelText.anchor.x = 0.5;
this.labelText.anchor.y = 1;
this.labelText.x += this.labelText.width / 2 + this.labelXOffset;
graphics.drawRect(
this.position[0] +
(labelLeftMargin || labelTopMargin) +
this.labelXOffset,
this.position[1] +
this.dimensions[1] -
this.labelText.height -
labelBackgroundMargin -
(labelBottomMargin || labelRightMargin),
this.labelText.width + labelBackgroundMargin,
this.labelText.height + labelBackgroundMargin,
);
} else if (
(this.options.labelPosition === 'topRight' && !this.flipText) ||
(this.options.labelPosition === 'bottomLeft' && this.flipText)
) {
this.labelText.x =
this.position[0] +
this.dimensions[0] -
(labelRightMargin || labelBottomMargin);
this.labelText.y = this.position[1] + (labelTopMargin || labelLeftMargin);
this.labelText.anchor.x = 0.5;
this.labelText.anchor.y = 0;
this.labelText.x -= this.labelText.width / 2 + this.labelXOffset;
graphics.drawRect(
this.position[0] +
this.dimensions[0] -
this.labelText.width -
labelBackgroundMargin -
(labelRightMargin || labelBottomMargin) -
this.labelXOffset,
this.position[1] + (labelTopMargin || labelLeftMargin),
this.labelText.width + labelBackgroundMargin,
this.labelText.height + labelBackgroundMargin,
);
} else if (this.options.labelPosition === 'bottomRight') {
this.labelText.x =
this.position[0] + this.dimensions[0] - labelRightMargin;
this.labelText.y =
this.position[1] + this.dimensions[1] - labelBottomMargin;
this.labelText.anchor.x = 0.5;
this.labelText.anchor.y = 1;
// we set the anchor to 0.5 so that we can flip the text if the track
// is rotated but that means we have to adjust its position
this.labelText.x -= this.labelText.width / 2 + this.labelXOffset;
graphics.drawRect(
this.position[0] +
this.dimensions[0] -
this.labelText.width -
labelBackgroundMargin -
labelRightMargin -
this.labelXOffset,
this.position[1] +
this.dimensions[1] -
this.labelText.height -
labelBackgroundMargin -
labelBottomMargin,
this.labelText.width + labelBackgroundMargin,
this.labelText.height + labelBackgroundMargin,
);
} else if (
(this.options.labelPosition === 'outerLeft' && !this.flipText) ||
(this.options.labelPosition === 'outerTop' && this.flipText)
) {
this.labelText.x = this.position[0];
this.labelText.y = this.position[1] + this.dimensions[1] / 2;
this.labelText.anchor.x = 0.5;
this.labelText.anchor.y = 0.5;
this.labelText.x -= this.labelText.width / 2 + 3;
} else if (
(this.options.labelPosition === 'outerTop' && !this.flipText) ||
(this.options.labelPosition === 'outerLeft' && this.flipText)
) {
this.labelText.x = this.position[0] + this.dimensions[0] / 2;
this.labelText.y = this.position[1];
this.labelText.anchor.x = 0.5;
this.labelText.anchor.y = 0.5;
this.labelText.y -= this.labelText.height / 2 + 3;
} else if (
(this.options.labelPosition === 'outerBottom' && !this.flipText) ||
(this.options.labelPosition === 'outerRight' && this.flipText)
) {
this.labelText.x = this.position[0] + this.dimensions[0] / 2;
this.labelText.y = this.position[1] + this.dimensions[1];
this.labelText.anchor.x = 0.5;
this.labelText.anchor.y = 0.5;
this.labelText.y += this.labelText.height / 2 + 3;
} else if (
(this.options.labelPosition === 'outerRight' && !this.flipText) ||
(this.options.labelPosition === 'outerBottom' && this.flipText)
) {
this.labelText.x = this.position[0] + this.dimensions[0];
this.labelText.y = this.position[1] + this.dimensions[1] / 2;
this.labelText.anchor.x = 0.5;
this.labelText.anchor.y = 0.5;
this.labelText.x += this.labelText.width / 2 + 3;
} else {
this.labelText.visible = false;
}
if (
this.options.labelPosition === 'outerLeft' ||
this.options.labelPosition === 'outerRight' ||
this.options.labelPosition === 'outerTop' ||
this.options.labelPosition === 'outerBottom'
) {
this.pLabel.setParent(this.pBase);
} else {
this.pLabel.setParent(this.pMasked);
}
}
/** @param {Options} options */
rerender(options) {
this.options = options;
this.draw();
this.drawBackground();
this.drawLabel();
this.drawError();
this.drawBorder();
}
/**
* Draw all the data associated with this track
*/
draw() {
// this rectangle is cleared by functions that override this draw method
// this.drawBorder();
// this.drawLabel();
this.drawError();
}
/**
* Export an SVG representation of this track
*
* @returns {[HTMLElement, HTMLElement]} The two returned DOM nodes are both SVG
* elements [base, track]. Base is a parent which contains track as a
* child. Track is clipped with a clipping rectangle contained in base.
*
*/
exportSVG() {
const gBase = document.createElement('g');
const rectBackground = document.createElement('rect');
rectBackground.setAttribute('x', `${this.position[0]}`);
rectBackground.setAttribute('y', `${this.position[1]}`);
rectBackground.setAttribute('width', `${this.dimensions[0]}`);
rectBackground.setAttribute('height', `${this.dimensions[1]}`);
if (this.options?.backgroundColor) {
rectBackground.setAttribute('fill', this.options.backgroundColor);
} else {
rectBackground.setAttribute('fill-opacity', '0');
}
const gClipped = document.createElement('g');
gClipped.setAttribute('class', 'g-clipped');
gBase.appendChild(gClipped);
gClipped.appendChild(rectBackground);
const gTrack = document.createElement('g');
gClipped.setAttribute('class', 'g-track');
gClipped.appendChild(gTrack);
const gLabels = document.createElement('g');
gClipped.setAttribute('class', 'g-labels');
gClipped.appendChild(gLabels); // labels should always appear on top of the track
// define the clipping area as a polygon defined by the track's
// dimensions on the canvas
const clipPath = document.createElementNS(
'http://www.w3.org/2000/svg',
'clipPath',
);
gBase.appendChild(clipPath);
const clipPolygon = document.createElementNS(
'http://www.w3.org/2000/svg',
'polygon',
);
clipPath.appendChild(clipPolygon);
clipPolygon.setAttribute(
'points',
`${this.position[0]},${this.position[1]} ` +
`${this.position[0] + this.dimensions[0]},${this.position[1]} ` +
`${this.position[0] + this.dimensions[0]},${
this.position[1] + this.dimensions[1]
} ` +
`${this.position[0]},${this.position[1] + this.dimensions[1]} `,
);
// the clipping area needs to be a clipPath element
const clipPathId = slugid.nice();
clipPath.setAttribute('id', clipPathId);
gClipped.setAttribute('style', `clip-path:url(#${clipPathId});`);
const lineParts = this.labelText.text.split('\n');
let ddy = 0;
// SVG text alignment is wonky, just adjust the dy values of the tspans
// instead
const paddingBottom = 3;
const labelTextHeight =
(this.labelTextFontSize + 2) * lineParts.length + paddingBottom;
if (this.labelText.anchor.y === 0.5) {
ddy = labelTextHeight / 2;
} else if (this.labelText.anchor.y === 1) {
ddy = -labelTextHeight;
}
for (let i = 0; i < lineParts.length; i++) {
const text = document.createElement('text');
text.setAttribute('font-family', this.labelTextFontFamily);
text.setAttribute('font-size', `${this.labelTextFontSize}px`);
// break up newlines into separate tspan elements because SVG text
// doesn't support line breaks:
// http://stackoverflow.com/a/16701952/899470
text.innerText = lineParts[i];
if (
this.options.labelPosition === 'topLeft' ||
this.options.labelPosition === 'topRight'
) {
const dy = ddy + (i + 1) * (this.labelTextFontSize + 2);
text.setAttribute('dy', String(dy));
} else if (
this.options.labelPosition === 'bottomLeft' ||
this.options.labelPosition === 'bottomRight'
) {
text.setAttribute('dy', String(ddy + i * (this.labelTextFontSize + 2)));
}
text.setAttribute('fill', this.options.labelColor ?? '');
if (this.labelText.anchor.x === 0.5) {
text.setAttribute('text-anchor', 'middle');
} else if (this.labelText.anchor.x === 1) {
text.setAttribute('text-anchor', 'end');
}
gLabels.appendChild(text);
}
gLabels.setAttribute(
'transform',
`translate(${this.labelText.x},${this.labelText.y})scale(${this.labelText.scale.x},1)`,
);
// return the whole SVG and where the specific track should draw its
// contents
return [gBase, gTrack];
}
/**
* @returns {number}
*/
calculateZoomLevel() {
throw new Error('Must be implemented by subclass');
}
}
export default PixiTrack;