UNPKG

ideogram

Version:

Chromosome visualization for the web

732 lines (626 loc) 24.6 kB
import { adjustBrightness, ensureContrast, getTextSize, d3 } from '../lib'; import {density1d} from 'fast-kde'; const MINI_CURVE_HEIGHT = 12; const MINI_CURVE_WIDTH = 48; /** Copyedit machine-friendly tissue name to human-friendly GTEx convention */ function refineTissueName(rawName) { let name = rawName.replace(/_/g, ' ').toLowerCase(); // Style abbreviations of "Brodmann area", and other terms // per GTEx conventions [ 'ba24', 'ba9', 'basal ganglia', 'omentum', 'suprapubic', 'lower leg', 'cervical c-1' ].forEach(term => name = name.replace(term, '(' + term + ')')); ['ba24', 'ba9', 'ebv'].forEach(term => { name = name.replace(term, term.toUpperCase()); }); [ 'adipose', 'artery', 'brain', 'breast', 'cells', 'cervix', 'colon', 'heart', 'kidney', 'muscle', 'nerve', 'skin', 'small intestine' ].forEach(term => { name = name.replace(term, term + ' -'); }); // Shorten from long full name to brief (but also standard) abbreviation name = name.replace('basal ganglia', 'BG'); name = name[0].toUpperCase() + name.slice(1); return name; } /** Get maximum expression among tissues, or for an optional reference */ function getMaxExpression(tissueExpressions, refTissue) { let maxExpression = 0; for (let i = 0; i < tissueExpressions.length; i++) { const teObject = tissueExpressions[i]; const thisMaxExp = teObject.expression.max; if (!refTissue) { // For default display of mini-curves if (thisMaxExp > maxExpression) { maxExpression = thisMaxExp; } } else { // Set a non-default tissue as reference, e.g. to frame other // mini-curves relative to hovered mini-curve. if (teObject.tissue === refTissue) { maxExpression = thisMaxExp; break; } } } return maxExpression; } /** * Set a `px` property in each item of tissueExpressions for key metrics * * @param {List<Object>} tissueExpressions * @param {Number} maxPx Maximum width * @param {Boolean} relative Whether offsets are relative to highest-median * expression tissue (e.g. multiple mini-curves) or not (e.g. detail curve) * @param {Number} leftPx How much to much curves over from the left * @param {String} refTissue Tissue used as reference for maximum expression. * refTissue maxExp becomes px.max, any greater exp. in other tissues gets * truncated at right in its curve. * @returns {List<Object>} tissueExpressions, with a new `px` property in each * for max, q3, median, q1, and min. */ function setPxOffset( tissueExpressions, maxPx, relative=true, leftPx=0, refTissue=null ) { const maxExpression = getMaxExpression(tissueExpressions, refTissue); let refMinExp = 0; if (refTissue) { const refTeObject = tissueExpressions.find(teObject => teObject.tissue === refTissue); refMinExp = refTeObject.expression.min; } const metrics = ['max', 'q3', 'median', 'q1', 'min']; tissueExpressions.map(teObject => { teObject.px = {}; if (relative) { for (let i = 0; i < metrics.length; i++) { const metric = metrics[i]; const exp = teObject.expression[metric]; let px = maxPx * (exp - refMinExp)/(maxExpression - refMinExp) + leftPx; if (Math.round(px) > maxPx) { // Often occurs when `refTissue` is specified teObject.px[metric + 'Raw'] = px; px += (maxPx - px); } px += leftPx; teObject.px[metric] = px; } } else { // min = 50, med = 100, max = 200 // px.min = 0, px.med = 250 * 100-50/(200 - 50), px.max = 250 const minExp = teObject.expression.min; const maxExp = teObject.expression.max; for (let i = 0; i < metrics.length; i++) { const metric = metrics[i]; const exp = teObject.expression[metric]; const px = maxPx * (exp - minExp)/(maxExp - minExp) + leftPx; teObject.px[metric] = px; } } return teObject; }); return tissueExpressions; } /** Get link to full detail about gene on GTEx */ function getFullDetail(gene) { const gtexUrl = `https://www.gtexportal.org/home/gene/${gene}`; const cls = 'class="_ideoGtexLink"'; const gtexLink = `<a href="${gtexUrl}" ${cls} target="_blank">GTEx</a>`; const fullDetail = `<i>Full detail: ${gtexLink}</i>`; return fullDetail; } function getMoreOrLessToggle(gene, height, tissueExpressions, ideo) { const fullDetail = getFullDetail(gene); if (tissueExpressions.length <= 3) { return `<br/>${fullDetail}<br/><br/>`; } const pipeStyle = 'style="margin: 0 6px; color: #CCC;"'; const details = `<span ${pipeStyle}>|</span>${fullDetail}`; const moreOrLess = !ideo.showTissuesMore ? `Less...` : 'More...'; const mlStyle = 'style="cursor: pointer;px;"'; const left = `left: ${!ideo.showTissuesMore ? 1 : -50}px;`; const top = 'top: -1px;'; const mltStyle = `style="position: relative; ${left} ${top} font-size: ${height}px"` const moreOrLessToggleHtml = `<div ${mltStyle}>` + `<a class="_ideoMoreOrLessTissue" ${mlStyle}>${moreOrLess}</a>` + `${!ideo.showTissuesMore ? details : ''}` + `</div>`; return moreOrLessToggleHtml; } /** Get x, y1, y2, and style for a metric line */ function getMetricLineAttrs(offsets, metric, y, height, isShifted=false) { let x = offsets[metric]; let isTruncated = false; if (isShifted && x > MINI_CURVE_WIDTH) { x = MINI_CURVE_WIDTH + 1; isTruncated = true; } let metricHeight = !isTruncated ? offsets[metric + 'Height'] : MINI_CURVE_HEIGHT; if (isNaN(metricHeight)) { // Seen upon e.g. hovering over "Artery - Coronary" in STAT1 metricHeight = MINI_CURVE_HEIGHT; } const top = height - metricHeight; const y1 = top + y + 0.5; const y2 = top + y + metricHeight; // E.g. brain mini-curve in AGT const isNarrow = offsets.max - offsets.min <= 8; const isMedian = metric === 'median'; const isNarrowMedian = isNarrow && isMedian; const style = isNarrowMedian ? 'display: none' : ''; // Whether to hide median at end of transition, e.g. when focus is // esophagus in ACE2 and testis median should be hidden const isTruncatedMedian = isTruncated && isMedian; const endStyle = isTruncatedMedian || isNarrowMedian ? 'display: none;' : ''; return {x, y1, y2, style, endStyle}; } /** Get a vertical line to show in distribution curve for median, Q1, or Q2 */ function getMetricLine( metric, offsets, color, y, height, dash=false ) { const classMetric = metric[0].toUpperCase() + metric.slice(1); const {x, y1, y2, style} = getMetricLineAttrs(offsets, metric, y, height); const styleAttr = style === '' ? '' : `style="${style}"`; const baseColor = adjustBrightness(color, 0.55); const strokeColor = ensureContrast(baseColor, color); const dasharray = dash ? 'stroke-dasharray="3" ' : ''; const attrs = `x1="${x}" y1="${y1}" x2="${x}" y2="${y2}" ${styleAttr} ` + dasharray + `class="_ideoExpression${classMetric}" `; const metricLine = `<line stroke="${strokeColor}" ${attrs} />`; return metricLine; } /** * Get vertical lines for median, Q1, Q3, to overlay in distribution curve plot * * Median line is solid, and shown in both mini-curve and detailed curve. * Q1 and Q3 lines are dashed, and only shown in detailed curve. */ function getMetricLines(offsets, y, height, color) { const medianLine = getMetricLine('median', offsets, color, y, height, false); const q1Line = getMetricLine('q1', offsets, color, y, height, true); const q3Line = getMetricLine('q3', offsets, color, y, height, true); return [medianLine, q1Line, q3Line]; } function getCurveShape(teObject, y, height, numKdeBins=64, isShifted=false) { const quantiles = teObject.expression.quantiles; const offsets = teObject.px; const samples = teObject.samples; const spreadQuantiles = []; // `quantiles` is an array encoding a histogram. // To get a kernel density estimation (KDE) -- i.e., a curve that smooths the // crude bars of the histogram -- we need to "spread" or "flatten" the // histogram array so e.g. // [0, 5, 4, 1] (= 0 samples in quantile 1, 5 samples in quantile 2, etc.) // becomes // [0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 3] quantiles.map((quantileCount, j) => { for (let k = 0; k < quantileCount; k++) { spreadQuantiles.push(j); } }); const sampleThreshold = 70; // GTEx sample threshold // Small bandwidth : sharp curve :: large bandwidth : smooth curve // // Increasing bandwidth when there are few samples helps avoid sharp curves // that are mere artifacts of having few points, which would be problematic // as it almost certainly misrepresents the underlying population. const bandwidth = samples >= sampleThreshold ? 0.7 : 1.5; const numBins = numKdeBins; // The number of lines in the KDE curve const kde = density1d( spreadQuantiles, {bins: numBins, bandwidth} ); const rawKdeArray = Array.from(kde); const kdeArray = rawKdeArray .filter(point => 0 <= point.x && point.x <= 10); if (kdeArray.length === 0) return ''; // Get scaling factor to convert kernel coordinates to pixels const maxKernelY = Math.max(...kdeArray.map(p => p.y)); const minKernelX = kdeArray[0].x; const maxKernelX = kdeArray.slice(-1)[0].x; const kdeWidth = maxKernelX - minKernelX; const thisMax = ('maxRaw' in offsets) ? offsets.maxRaw : offsets.max; const offsetsWidth = thisMax - offsets.min; const pixelsPerKernel = offsetsWidth/kdeWidth; const bottom = height + y; // Convert KDE x,y points to pixel coordinates, each a segment of the curve; // and set heights for each metric plotted in detailed distribution let prevPixelX = 0; const rawPoints = kdeArray.map((point, i) => { let pixelX = (point.x - minKernelX) * pixelsPerKernel + offsets.min; if (isShifted && pixelX > MINI_CURVE_WIDTH) { pixelX = MINI_CURVE_WIDTH; } const segmentHeight = height * (point.y / maxKernelY); const pixelY = bottom - segmentHeight; if (i > 0) { ['q1', 'median', 'q3'].forEach(metric => { const metricX = offsets[metric]; if (prevPixelX < metricX && metricX <= pixelX) { offsets[metric + 'Height'] = segmentHeight; } }); } prevPixelX = pixelX; return `${pixelX},${pixelY}`; }); let refinedMax = offsets.max; let refinedMin = offsets.min; if (isShifted) { if (offsets.max > MINI_CURVE_WIDTH) refinedMax = MINI_CURVE_WIDTH; if (offsets.min > MINI_CURVE_WIDTH) refinedMin = MINI_CURVE_WIDTH; } // Tie up loose ends of the curved diagram rawPoints.push(refinedMax + ',' + bottom); rawPoints.push(refinedMin + ',' + bottom); const originPoint = rawPoints[0]; rawPoints.push(originPoint); const points = rawPoints.join(' '); return [points, offsets]; } /** * Get a distribution curve of expression, via kernel density estimation (KDE) */ function getCurve(teObject, y, height, color, borderColor, numKdeBins=64) { const [points, offsets] = getCurveShape(teObject, y, height, numKdeBins); const curveAttrs = `fill="${color}" ` + `stroke="${borderColor}" ` + `points="${points}" ` + 'class="tissue-curve" ' + `data-tissue-curve="${teObject.tissue}"`; const curve = `<polyline ${curveAttrs} />`; return [curve, offsets]; } /** * Remove detailed distribution curve; unhide RNA & protein diagrams, footer */ function removeDetailedCurve() { const container = document.querySelector('._ideoDistributionContainer'); if (!container) return; container.remove(); const structureDom = document.querySelector('._ideoGeneStructureContainer'); if (structureDom) { structureDom.style.display = ''; } const footer = document.querySelector('._ideoTooltipFooter'); footer.style.display = ''; } /** * Get small vertical lines ("ticks") for min, max, and median in detailed * distribution curve */ function getMetricTicks(teObject, height) { const min = teObject.px.min; const minExp = teObject.expression.min; const max = teObject.px.max; const maxExp = teObject.expression.max; const median = teObject.px.median; const medianExp = teObject.expression.median; const mid = max/2; const y = height + 5; const stroke = `stroke="#CCC" stroke-width="1px"`; const fontObject = { config: {weight: 400, annotLabelSize: 12} }; const textY = y + 16; const expTextY = y + 27; const tickY1 = y - 3; const tickY2 = y + 5; const maxRawText = 'Max'; const maxTextWidth = getTextSize(maxRawText, fontObject).width; const maxExpTextWidth = getTextSize(maxExp, fontObject).width; const maxTextX = max - maxTextWidth / 2; const maxExpTextX = max - maxExpTextWidth / 2; const maxTickAttrs = `x1="${max}" x2="${max}" y1="${tickY1}" y2="${tickY2}" ${stroke}`; const maxText = `<line ${maxTickAttrs} />` + `<text x="${maxTextX}" y="${textY}">${maxRawText}.</text>` + `<text x="${maxExpTextX}" y="${expTextY}">${maxExp}</text>`; const minRawText = 'Min'; const minTextWidth = getTextSize(minRawText, fontObject).width; const minExpTextWidth = getTextSize(minExp, fontObject).width; let minTextX = min - minTextWidth / 2; let minExpTextX = min - minExpTextWidth / 2; const minTextEndX = min + minTextWidth; const minTickAttrs = `x1="${min}" x2="${min}" y1="${tickY1}" y2="${tickY2}" ${stroke}`; const medianRawText = 'Median'; const medianTextWidth = getTextSize(medianRawText, fontObject).width; const medianExpTextWidth = getTextSize(medianExp, fontObject).width; const medianTextX = median - medianTextWidth / 2; const medianExpTextX = median - medianExpTextWidth / 2; const medianTickAttrs = `x1="${median}" x2="${median}" y1="${tickY1}" y2="${tickY2}" ${stroke}`; let medianX = medianTextX; let medianExpX = medianExpTextX; // Align "Median" to right of tick if text would clash with "Min." const isMinMedSoftCollide = minTextEndX >= medianX; if (isMinMedSoftCollide) { medianX = median; medianExpX = median; } // If right-aligning "Median" doesn't fix clash, // then left-align "Min." and nudge "Median" right const isMinMedCollide = minTextEndX >= medianX; // Examples: "More..." tissues in PCSK9 and (especially) TTN if (isMinMedCollide) { medianX += 1.5; medianExpX += 1.5; minTextX = min - minTextWidth - 1.5; minExpTextX = min - minExpTextWidth - 1.5; } const medianText = `<line ${medianTickAttrs} />` + `<text x="${medianX}" y="${textY}">Median</text>` + `<text x="${medianExpX}" y="${expTextY}">${medianExp}</text>`; const minText = `<line ${minTickAttrs} />` + `<text x="${minTextX}" y="${textY}" >${minRawText}.</text>` + `<text x="${minExpTextX}" y="${expTextY}" >${minExp}</text>`; const nameAttrs = `x="${mid - 40}" y="${y + 46}"`; const sampleAttrs = `x="${mid - 70}" y="${y + 59}"`; return ( `<g>` + minText + maxText + medianText + `<text ${nameAttrs}>Expression (TPM)</text>` + `<text ${sampleAttrs}>Samples: ${teObject.samples} | Source: GTEx</text>` + `</g>` ); } /** * Write a large, detailed distribution curve to the DOM. * * This is shown upon hovering over a mini-curve. The detailed curve shows * more metrics than the mini-curve, in a zoomed-in view that makes it easier * to discern the overall shape and local features of gene expression * distribution in the tissue. */ function addDetailedCurve(traceDom, ideo) { const gene = traceDom.getAttribute('data-gene'); const tissue = traceDom.getAttribute('data-tissue'); const tissueExpressions = Ideogram.tissueExpressionsByGene[gene]; let teObject = tissueExpressions.find(t => t.tissue === tissue); const maxWidthPx = 225; // Same width as RNA & protein diagrams const leftPx = 35; teObject = setPxOffset( [teObject], maxWidthPx, false, leftPx )[0]; const y = 0; const height = 50; const color = `#${teObject.color}`; const borderColor = adjustBrightness(color, 0.85); const numBins = 256; const [distributionCurve, offsetsWithHeight] = getCurve( teObject, y + 1, height, color, borderColor, numBins ); const [medianLine, q1Line, q3Line] = getMetricLines( offsetsWithHeight, y, height, color ); const metricTicks = getMetricTicks(teObject, height); // Hide RNA & protein diagrams, footer let ledgeDom; const structureDom = document.querySelector('._ideoGeneStructureContainer'); const footer = document.querySelector('._ideoTooltipFooter'); if (structureDom) { // Account for e.g. ncRNA, like MALAT1 ledgeDom = structureDom; structureDom.style.display = 'none'; footer.style.display = 'none'; } else { ledgeDom = footer; const plotContainer = document.querySelector('._ideoTissuePlotContainer'); plotContainer.setAttribute('style', 'margin-bottom: 20px'); } const svgHeight = 119.5; // Keeps tooltip bottom flush with prior state const style = `style="position: relative; height: ${svgHeight}px"`; const svgStyle = 'style="position: absolute; top: 2px; left: -5px;"'; const container = `<div class="_ideoDistributionContainer" ${style}>` + `<svg width="280px" height="${svgHeight}px" ${svgStyle}>` + metricTicks + distributionCurve + medianLine + q1Line + q3Line + `</svg>` + '</div>'; ledgeDom.insertAdjacentHTML('beforebegin', container); } function getMiniCurveY(i, height) { const y = 1 + i * (height + 2); return y; } /** Get mini distribution curves and */ function getExpressionPlotHtml(gene, tissueExpressions, ideo) { const maxWidth = MINI_CURVE_WIDTH; tissueExpressions = setPxOffset(tissueExpressions, maxWidth, true, 0); const height = MINI_CURVE_HEIGHT; const moreOrLessToggleHtml = getMoreOrLessToggle(gene, height, tissueExpressions, ideo); const numTissues = !ideo.showTissuesMore ? 10 : 3; let y; const rects = tissueExpressions.slice(0, numTissues).map((teObject, i) => { y = getMiniCurveY(i, height); const tissue = refineTissueName(teObject.tissue); const color = `#${teObject.color}`; const borderColor = adjustBrightness(color, 0.85); const [distributionCurve, offsetsWithHeight] = getCurve( teObject, y, height, color, borderColor ); const [medianLine] = getMetricLines(offsetsWithHeight, y, height, color); const dataTissue = `data-tissue="${teObject.tissue}"`; // Invisible; enables tooltip upon hover anywhere in diagram area, // not merely the (potentially very small) diagram itself const containerAttrs = `height="${height + 2}" ` + `width="${maxWidth}px" ` + 'fill="#FFF" ' + 'opacity="0" ' + `x="0" ` + `y="${y}" ` + `data-gene="${gene}" ` + dataTissue; const textAttrs = `y="${y + height}" ` + `style="font-size: ${height}px;" ` + `x="${maxWidth + 10}" ` + dataTissue; return ( `<g data-group-tissue="${teObject.tissue}">` + `<text ${textAttrs}>${tissue}</text>` + distributionCurve + medianLine + `<rect ${containerAttrs} class="_ideoExpressionTrace" />` + '</g>' ); }).join(''); let containerStyle = 'style="margin-bottom: 30px;"'; const hasStructure = gene in Ideogram.geneStructureCache; if (!hasStructure) { // e.g. MALAT1 containerStyle = 'style="margin-bottom: 10px;"'; } const plotAttrs = `style="margin-top: 15px; margin-bottom: -15px;"`; const cls = 'class="_ideoTissuePlotTitle"'; const titleAttrs = `${cls} style="margin-bottom: 4px;"`; const style = `style="position: relative; left: 10px"`; const plotHtml = `<div class="_ideoTissuePlotContainer" ${containerStyle}>` + `<div class="_ideoTissueExpressionPlot" ${plotAttrs}> <div ${titleAttrs}>Reference expression by tissue</div> <svg width="275" height="${y + height + 2}" ${style}>${rects}</svg> ${moreOrLessToggleHtml} </div>` + '</div>'; return plotHtml; } function updateTissueExpressionPlot(ideo) { const plot = document.querySelector('._ideoTissueExpressionPlot'); const plotParent = plot.parentElement; const gene = document.querySelector('#ideo-related-gene').innerText; const tissueExpressions = Ideogram.tissueExpressionsByGene[gene]; const newPlotHtml = getExpressionPlotHtml(gene, tissueExpressions, ideo); plotParent.innerHTML = newPlotHtml; addTissueListeners(ideo); } function colorTissueText(traceDom, color) { const tissue = traceDom.getAttribute('data-tissue'); const tissueTextDom = document.querySelector(`text[data-tissue="${tissue}"]`); tissueTextDom.setAttribute('fill', color); } export function addTissueListeners(ideo) { const moreOrLess = document.querySelector('._ideoMoreOrLessTissue'); if (moreOrLess) { moreOrLess.addEventListener('click', (event) => { event.stopPropagation(); event.preventDefault(); ideo.showTissuesMore = !ideo.showTissuesMore; updateTissueExpressionPlot(ideo); }); } const traces = document.querySelectorAll('._ideoExpressionTrace') traces.forEach(trace => { trace.addEventListener('mouseenter', () => { colorTissueText(trace, '#338'); focusMiniCurve(trace, ideo); addDetailedCurve(trace, ideo); }); trace.addEventListener('mouseleave', () => { colorTissueText(trace, '#000'); focusMiniCurve(traces[0], ideo, true); removeDetailedCurve(trace, ideo); }); }); } /** * Update mini-curve shapes to focus on hovered tissue * * This helps compare the focused tissue to other tissues. Without this, * often almost all curves are too small, and their distributions can't be * richly compared, because one or a few tissues have a maximum drastically * larger than others. With this feature, expression in those non-dominant * tissues (which are often the majority, and biologically relevant) can be * compared in detail. * * The focused tissue becomes the new coordinate reference for all mini-curves. * The new reference tissue (refTissue) gets scaled and translated to occupy * the full width available to mini-curves (MINI_CURVE_WIDTH). Other curves get * transformed to be viewed from the perspective of the focused tissue. **/ function focusMiniCurve(traceDom, ideo, reset=false) { const gene = traceDom.getAttribute('data-gene'); const refTissue = reset ? null : traceDom.getAttribute('data-tissue'); const numTissues = !ideo.showTissuesMore ? 10 : 3; let tissueExpressions = Ideogram.tissueExpressionsByGene[gene]; const maxPx = MINI_CURVE_WIDTH; const relative = true; const leftPx = 0; tissueExpressions = setPxOffset(tissueExpressions, maxPx, relative, leftPx, refTissue) .slice(0, numTissues); const height = MINI_CURVE_HEIGHT; tissueExpressions.forEach((teObject, i) => { const thisTissue = teObject.tissue; const thisTeObject = tissueExpressions.find(te => te.tissue === thisTissue); const y = getMiniCurveY(i, height); const isShifted = !reset; const [newPoints, newOffsets] = getCurveShape(thisTeObject, y, height, 64, isShifted); tissueExpressions[i].points = newPoints; const medianLineAttrs = getMetricLineAttrs(newOffsets, 'median', y, height, isShifted); tissueExpressions[i].medianLine = medianLineAttrs; }); d3.select('._ideoTissueExpressionPlot').selectAll('polyline') .data(tissueExpressions) .transition() .duration(500) .attr('points', (_, i) => tissueExpressions[i].points); d3.select('._ideoTissueExpressionPlot').selectAll('._ideoExpressionMedian') .data(tissueExpressions) .attr('style', (_, i) => tissueExpressions[i].medianLine.style) .transition() .duration(500) .attr('x1', (_, i) => tissueExpressions[i].medianLine.x) .attr('x2', (_, i) => tissueExpressions[i].medianLine.x) .attr('y1', (_, i) => tissueExpressions[i].medianLine.y1) .attr('y2', (_, i) => tissueExpressions[i].medianLine.y2) .attr('style', (_, i) => tissueExpressions[i].medianLine.endStyle); } export function getTissueHtml(annot, ideo) { if ( !Ideogram.tissueCache || !(annot.name in Ideogram.tissueCache.byteRangesByName) ) { // e.g. MIR23A return '<br/>'; } if (ideo.showTissuesMore === undefined) { ideo.showTissuesMore = true; } const gene = annot.name; const tissueExpressions = Ideogram.tissueExpressionsByGene[gene]; if (!tissueExpressions) return; const tissueHtml = getExpressionPlotHtml(gene, tissueExpressions, ideo); return tissueHtml; }