UNPKG

higlass

Version:

HiGlass Hi-C / genomic / large data viewer

620 lines (488 loc) 17.6 kB
// @ts-nocheck import boxIntersect from 'box-intersect'; import { format, formatPrefix, precisionPrefix } from 'd3-format'; import { scaleLinear } from 'd3-scale'; import ChromosomeInfo from './ChromosomeInfo'; import TiledPixiTrack from './TiledPixiTrack'; import { absToChr, colorToHex, pixiTextToSvg, showMousePosition, svgLine, } from './utils'; import { GLOBALS, THEME_DARK } from './configs'; const TICK_WIDTH = 200; const TICK_HEIGHT = 6; const TICK_TEXT_SEPARATION = 2; const TICK_COLOR = 0x777777; class HorizontalChromosomeLabels extends TiledPixiTrack { constructor(context, options) { super(context, options); const { dataConfig, animate, chromInfoPath, isShowGlobalMousePosition } = context; this.chromInfo = null; this.dataConfig = dataConfig; this.pTicks = new GLOBALS.PIXI.Graphics(); this.pMain.addChild(this.pTicks); this.gTicks = {}; this.tickTexts = {}; this.options = options; this.isShowGlobalMousePosition = isShowGlobalMousePosition; this.textFontSize = 12; this.textFontFamily = 'Arial'; this.textFontColor = '#808080'; this.textStrokeColor = this.getTheme() === THEME_DARK ? '#000000' : '#ffffff'; this.pixiTextConfig = { fontSize: +this.options.fontSize ? `${+this.options.fontSize}px` : `${this.textFontSize}px`, fontFamily: this.textFontFamily, fill: this.options.color || this.textFontColor, lineJoin: 'round', stroke: this.options.stroke || this.textStrokeColor, strokeThickness: 2, }; this.stroke = colorToHex(this.pixiTextConfig.stroke); // text objects to use if the tick style is "bounds", meaning // we only draw two ticks on the left and the right of the screen this.tickWidth = TICK_WIDTH; this.tickHeight = TICK_HEIGHT; this.tickTextSeparation = TICK_TEXT_SEPARATION; this.tickColor = this.options.tickColor ? colorToHex(this.options.tickColor) : TICK_COLOR; this.animate = animate; this.pubSubs = []; if (this.options.showMousePosition && !this.hideMousePosition) { this.hideMousePosition = showMousePosition( this, this.is2d, this.isShowGlobalMousePosition(), ); } /** The previous chromInfoPath approach to loading chromosome size. * * This is superseded by loading chromosome sizes directly from the * tileset info object */ if (chromInfoPath) { ChromosomeInfo( chromInfoPath, (newChromInfo) => { this.chromInfo = newChromInfo; this.tilesetInfoReceived(); }, this.pubSub, ); } } tilesetInfoReceived() { this.rerender(this.options, true); this.draw(); this.animate(); } calculateVisibleTiles() { /** This track does not use tiles so return an empty set */ return []; } initBoundsTicks() { if (this.pTicks) { this.pMain.removeChild(this.pTicks); this.pTicks = null; } if (!this.gBoundTicks) { this.gBoundTicks = new GLOBALS.PIXI.Graphics(); this.leftBoundTick = new GLOBALS.PIXI.Text('', this.pixiTextConfig); this.rightBoundTick = new GLOBALS.PIXI.Text('', this.pixiTextConfig); this.gBoundTicks.addChild(this.leftBoundTick); this.gBoundTicks.addChild(this.rightBoundTick); this.pMain.addChild(this.gBoundTicks); } this.texts = []; } initChromLabels() { if (!this.chromInfo) return; if (this.gBoundTicks) { this.pMain.removeChild(this.gBoundTicks); this.gBoundTicks = null; } if (!this.pTicks) { this.pTicks = new GLOBALS.PIXI.Graphics(); this.pMain.addChild(this.pTicks); } this.texts = []; this.pTicks.removeChildren(); for (let i = 0; i < this.chromInfo.cumPositions.length; i++) { const chromName = this.chromInfo.cumPositions[i].chr; this.gTicks[chromName] = new GLOBALS.PIXI.Graphics(); // create the array that will store tick TEXT objects if (!this.tickTexts[chromName]) this.tickTexts[chromName] = []; const text = new GLOBALS.PIXI.Text(chromName, this.pixiTextConfig); // give each string a random hash so that some get hidden // when there's overlaps text.hashValue = Math.random(); this.pTicks.addChild(text); this.pTicks.addChild(this.gTicks[chromName]); this.texts.push(text); } } rerender(options, force) { const strOptions = JSON.stringify(options); if (!force && strOptions === this.prevOptions) return; this.prevOptions = strOptions; this.options = options; this.tickTexts = {}; this.pixiTextConfig.fontSize = +this.options.fontSize ? `${+this.options.fontSize}px` : this.pixiTextConfig.fontSize; this.pixiTextConfig.fill = this.options.color || this.pixiTextConfig.fill; this.pixiTextConfig.stroke = this.options.stroke || this.pixiTextConfig.stroke; this.stroke = colorToHex(this.pixiTextConfig.stroke); this.tickColor = this.options.tickColor ? colorToHex(this.options.tickColor) : TICK_COLOR; if (this.options.tickPositions === 'ends') { this.initBoundsTicks(); } else { this.initChromLabels(); } super.rerender(options, force); if (this.options.showMousePosition && !this.hideMousePosition) { this.hideMousePosition = showMousePosition( this, this.is2d, this.isShowGlobalMousePosition(), ); } if (!this.options.showMousePosition && this.hideMousePosition) { this.hideMousePosition(); this.hideMousePosition = undefined; } } formatTick(pos) { const domain = this._xScale.domain(); const viewWidth = domain[1] - domain[0]; const p = precisionPrefix(pos, viewWidth); const fPlain = format(','); const fPrecision = formatPrefix(`,.${p}`, viewWidth); let f = fPlain; if (this.options.tickFormat === 'si') { f = fPrecision; } else if (this.options.tickFormat === 'plain') { f = fPlain; } else if (this.options.tickPositions === 'ends') { // if no format is specified but tickPositions are at 'ends' // then use precision format f = fPrecision; } return f(pos); } drawBoundsTicks(x1, x2) { const graphics = this.gBoundTicks; graphics.clear(); graphics.lineStyle(1, 0); // determine the stard and end positions of tick lines along the vertical axis const lineYStart = this.options.reverseOrientation ? 0 : this.dimensions[1]; const lineYEnd = this.options.reverseOrientation ? this.tickHeight : this.dimensions[1] - this.tickHeight; // left tick // line is offset by one because it's right on the edge of the // visible region and we want to get the full width graphics.moveTo(1, lineYStart); graphics.lineTo(1, lineYEnd); // right tick graphics.moveTo(this.dimensions[0] - 1, lineYStart); graphics.lineTo(this.dimensions[0] - 1, lineYEnd); // we want to control the precision of the tick labels // so that we don't end up with labels like 15.123131M this.leftBoundTick.x = 0; this.leftBoundTick.y = this.options.reverseOrientation ? lineYEnd + this.tickTextSeparation : lineYEnd - this.tickTextSeparation; this.leftBoundTick.text = `${x1[0]}: ${this.formatTick(x1[1])}`; this.leftBoundTick.anchor.y = this.options.reverseOrientation ? 0 : 1; this.rightBoundTick.x = this.dimensions[0]; this.rightBoundTick.text = `${x2[0]}: ${this.formatTick(x2[1])}`; this.rightBoundTick.y = this.options.reverseOrientation ? lineYEnd + this.tickTextSeparation : lineYEnd - this.tickTextSeparation; this.rightBoundTick.anchor.y = this.options.reverseOrientation ? 0 : 1; this.rightBoundTick.anchor.x = 1; if (this.flipText) { // this means this track is displayed vertically, so update the anchor and scale of labels to make them readable! this.leftBoundTick.scale.x = -1; this.leftBoundTick.anchor.x = 1; this.rightBoundTick.scale.x = -1; this.rightBoundTick.anchor.x = 0; } // line is offset by one because it's right on the edge of the // visible region and we want to get the full width this.leftBoundTick.tickLine = [ 1, this.dimensions[1], 1, this.dimensions[1] - this.tickHeight, ]; this.rightBoundTick.tickLine = [ this.dimensions[0] - 1, this.dimensions[1], this.dimensions[0] - 1, this.dimensions[1] - this.tickHeight, ]; this.tickTexts = {}; this.tickTexts.all = [this.leftBoundTick, this.rightBoundTick]; // this.rightBoundTick } drawTicks(cumPos) { const graphics = this.gTicks[cumPos.chr]; graphics.visible = true; // clear graphics *and* ticktexts otherwise the two are out of sync! graphics.clear(); const chromLen = +this.chromInfo.chromLengths[cumPos.chr]; const vpLeft = Math.max(this._xScale(cumPos.pos), 0); const vpRight = Math.min( this._xScale(cumPos.pos + chromLen), this.dimensions[0], ); const numTicks = (vpRight - vpLeft) / this.tickWidth; // what is the domain of this chromosome that is visible? const xScale = scaleLinear() .domain([ Math.max(1, this._xScale.invert(0) - cumPos.pos), Math.min( chromLen, this._xScale.invert(this.dimensions[0]) - cumPos.pos, ), ]) .range(vpLeft, vpRight); // calculate a certain number of ticks const ticks = xScale .ticks(numTicks) .filter((tick) => Number.isInteger(tick)); // not sure why we're separating these out by chromosome, but ok const tickTexts = this.tickTexts[cumPos.chr]; const tickHeight = this.options.fontIsLeftAligned ? (+this.options.fontSize || this.textFontSize) / 2 : this.tickHeight; const flipTextSign = this.flipText ? -1 : 1; const xPadding = this.options.fontIsLeftAligned ? flipTextSign * 4 : 0; let yPadding = this.options.fontIsLeftAligned ? 0 : tickHeight + this.tickTextSeparation; if (this.options.reverseOrientation) { yPadding = this.dimensions[1] - yPadding; } // these two loops reuse existing text objects so that // we're not constantly recreating texts that already // exist while (tickTexts.length < ticks.length) { const newText = new GLOBALS.PIXI.Text('', this.pixiTextConfig); tickTexts.push(newText); this.gTicks[cumPos.chr].addChild(newText); } while (tickTexts.length > ticks.length) { const text = tickTexts.pop(); this.gTicks[cumPos.chr].removeChild(text); } let i = 0; while (i < ticks.length) { tickTexts[i].visible = true; tickTexts[i].anchor.x = this.options.fontIsLeftAligned ? 0 : 0.5; tickTexts[i].anchor.y = this.options.reverseOrientation ? 0 : 1; if (this.flipText) tickTexts[i].scale.x = -1; // draw the tick labels tickTexts[i].x = this._xScale(cumPos.pos + ticks[i]) + xPadding; tickTexts[i].y = this.dimensions[1] - yPadding; tickTexts[i].text = ticks[i] === 0 ? `${cumPos.chr}: 1` : `${cumPos.chr}: ${this.formatTick(ticks[i])}`; const x = this._xScale(cumPos.pos + ticks[i]); // store the position of the tick line so that it can // be used in the export function tickTexts[i].tickLine = [ x - 1, this.dimensions[1], x - 1, this.dimensions[1] - tickHeight - 1, ]; // draw outline const lineYStart = this.options.reverseOrientation ? 0 : this.dimensions[1]; const lineYEnd = this.options.reverseOrientation ? tickHeight : this.dimensions[1] - tickHeight; graphics.lineStyle(1, this.stroke); graphics.moveTo(x - 1, lineYStart); graphics.lineTo(x - 1, lineYEnd - 1); if (this.options.fontIsLeftAligned) { graphics.lineTo(x + 2 * flipTextSign + 1 * flipTextSign, lineYEnd - 1); graphics.lineTo(x + 2 * flipTextSign + 1 * flipTextSign, lineYEnd + 1); graphics.lineTo(x + 1, lineYEnd + 1); } else { graphics.lineTo(x + 1, lineYEnd - 1); } graphics.lineTo(x + 1, lineYStart); // draw the tick lines graphics.lineStyle(1, this.tickColor); graphics.moveTo(x, lineYStart); graphics.lineTo(x, lineYEnd); if (this.options.fontIsLeftAligned) { graphics.lineTo(x + 2 * flipTextSign, lineYEnd); } i += 1; } while (i < tickTexts.length) { // we don't need this text so we'll turn it off for now tickTexts[i].visible = false; i += 1; } return ticks.length; } draw() { this.allTexts = []; if (!this.texts) return; const x1 = absToChr(this._xScale.domain()[0], this.chromInfo); const x2 = absToChr(this._xScale.domain()[1], this.chromInfo); if (!x1 || !x2) { console.warn('Empty chromInfo:', this.dataConfig, this.chromInfo); return; } if (this.options.tickPositions === 'ends') { if (!this.gBoundTicks) return; this.gBoundTicks.visible = true; this.drawBoundsTicks(x1, x2); return; } if (!this.pTicks) { // options.tickPositiosn was probably just changed to 'even' // and initChromLabels hasn't been called yet return; } for (let i = 0; i < this.texts.length; i++) { this.texts[i].visible = false; this.gTicks[this.chromInfo.cumPositions[i].chr].visible = false; } let yPadding = this.options.fontIsLeftAligned ? 0 : this.tickHeight + this.tickTextSeparation; if (this.options.reverseOrientation) { yPadding = this.dimensions[1] - yPadding; } // hide all the chromosome labels in preparation for drawing // new ones Object.keys(this.chromInfo.chrPositions).forEach((chrom) => { if (this.tickTexts[chrom]) { for (let j = 0; j < this.tickTexts[chrom].length; j++) { this.tickTexts[chrom][j].visible = false; } } }); // iterate over each chromosome for (let i = x1[3]; i <= x2[3]; i++) { const xCumPos = this.chromInfo.cumPositions[i]; const midX = xCumPos.pos + this.chromInfo.chromLengths[xCumPos.chr] / 2; const viewportMidX = this._xScale(midX); // This is ONLY the bare chromosome name. Not the tick label! const text = this.texts[i]; text.anchor.x = this.options.fontIsLeftAligned ? 0 : 0.5; text.anchor.y = this.options.reverseOrientation ? 0 : 1; text.x = viewportMidX; text.y = this.dimensions[1] - yPadding; text.updateTransform(); if (this.flipText) text.scale.x = -1; const numTicksDrawn = this.drawTicks(xCumPos); // only show chromsome labels if there's no ticks drawn text.visible = numTicksDrawn <= 0; this.allTexts.push({ importance: text.hashValue, text, caption: null, }); } // define the edge chromosome which are visible this.hideOverlaps(this.allTexts); } hideOverlaps(allTexts) { let allBoxes = []; // store the bounding boxes of the text objects so we can // calculate overlaps allBoxes = allTexts.map(({ text }, i) => { text.updateTransform(); const b = text.getBounds(); const box = [b.x, b.y, b.x + b.width, b.y + b.height]; return box; }); boxIntersect(allBoxes, (i, j) => { if (allTexts[i].importance > allTexts[j].importance) { allTexts[j].text.visible = false; } else { allTexts[i].text.visible = false; } }); } setPosition(newPosition) { super.setPosition(newPosition); [this.pMain.position.x, this.pMain.position.y] = this.position; } zoomed(newXScale, newYScale) { this.xScale(newXScale); this.yScale(newYScale); this.draw(); } exportSVG() { let track = null; let base = null; if (super.exportSVG) { [base, track] = super.exportSVG(); } else { base = document.createElement('g'); track = base; } base.setAttribute('class', 'chromosome-labels'); const output = document.createElement('g'); track.appendChild(output); output.setAttribute( 'transform', `translate(${this.position[0]},${this.position[1]})`, ); this.allTexts .filter((text) => text.text.visible) .forEach((text) => { const g = pixiTextToSvg(text.text); output.appendChild(g); }); Object.values(this.tickTexts).forEach((texts) => { texts .filter((x) => x.visible) .forEach((text) => { let g = pixiTextToSvg(text); output.appendChild(g); g = svgLine( text.x, this.options.reverseOrientation ? 0 : this.dimensions[1], text.x, this.options.reverseOrientation ? this.tickHeight : this.dimensions[1] - this.tickHeight, 1, this.tickColor, ); const line = document.createElement('line'); line.setAttribute('x1', text.tickLine[0]); line.setAttribute('y1', text.tickLine[1]); line.setAttribute('x2', text.tickLine[2]); line.setAttribute('y2', text.tickLine[3]); line.setAttribute('style', 'stroke: grey'); output.appendChild(g); output.appendChild(line); }); }); return [base, track]; } } export default HorizontalChromosomeLabels;