ideogram
Version:
Chromosome visualization for the web
1,204 lines (1,031 loc) • 39.2 kB
JavaScript
import tippy, {hideAll} from 'tippy.js';
import {tippyCss, tippyLightCss} from './tippy-styles';
// import 'tippy.js/themes/light.css';
import {d3} from '../lib';
import {getIcon} from '../annotations/legend';
import {getProtein, getHasTopology} from './protein';
import {getVariantsSvg} from './variant';
const y = 5;
// Subtle visual delimiter; separates horizontally adjacent fields in UI
export const pipe = `<span style='color: #CCC'>|</span>`;
const utr5 = "5'-UTR";
const utr3 = "3'-UTR";
const heights = {
"5'-UTR": 20,
'exon': 20,
'intron': 20,
"3'-UTR": 20
};
const colors = {
"5'-UTR": '#155069',
'exon': '#DAA521',
"intron": '#FFFFFF00',
"3'-UTR": '#357089'
};
const lineColors = {
"5'-UTR": '#70A099',
'exon': '#BA8501',
"3'-UTR": '#90C0B9'
};
const subpartClasses = {
"5'-UTR": 'five-prime-utr',
'exon': 'exon',
"3'-UTR": 'three-prime-utr',
'intron': 'intron'
};
const css =
`<style>
._ideoGeneStructureContainerName {
position: relative;
left: 45px;
margin-right: 20px;
}
._ideoGeneStructureContainerName.pre-mRNA {
left: 70px;
margin-right: 70px;
}
._ideoGeneStructureContainer rect:hover + line {
visibility: hidden;
}
._ideoGeneStructureContainer {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
._ideoGeneStructureContainer:hover ._ideoSpliceToggle {
visibility: visible;
}
._ideoGeneStructureContainer ._ideoSpliceToggle {
visibility: hidden;
}
._ideoSpliceToggle {
margin-left: 53px; background-color: #EEE;
}
._ideoSpliceToggle.pre-mRNA {
margin-left: 43px; background-color: #F8F8F8;
}
._ideoHoveredSubpart {
stroke: #D0D0DD !important; stroke-width: 3px;
}
#_ideoGeneStructureTip {
font-style: italic;
}
${tippyCss}
.tippy-box {
font-size: 12px;
}
.tippy-content {
padding: 3px 7px;
}
</style>`;
const hoverTip = '<span id="_ideoGeneStructureTip">Hover for details</span>';
/** Get DOM element for gene structure footer */
function getFooter() {
return document.querySelector('._ideoGeneStructureFooter');
}
/** Write transcript details below the diagram */
function writeFooter(container) {
const footer = getFooter();
const svgDOM = container.querySelector('svg');
const transcriptSummary = svgDOM.getAttribute('data-ideo-footer');
footer.innerHTML = ` <br/>${transcriptSummary}`;
}
/** Write newly-selected gene structure diagram, header, and footer */
async function updateGeneStructure(ideo, offset=0) {
const [structure, selectedIndex] = getSelectedStructure(ideo, offset);
const isCanonical = (selectedIndex === 0);
const menu = document.querySelector('#_ideoGeneStructureMenu');
menu.options[selectedIndex].selected = true;
const svgResults = await getSvg(structure, ideo, Ideogram.spliceExons);
const svg = svgResults[0];
const container = document.querySelector('._ideoGeneStructureSvgContainer');
container.innerHTML = svg;
updateHeader(Ideogram.spliceExons, isCanonical);
writeFooter(container);
ideo.addedSubpartListeners = false;
addHoverListeners(ideo);
initTippy(ideo);
}
/** Get gene symbol from transcript / gene structure name */
export function getGeneFromStructureName(structureName) {
const gene = structureName.split('-').slice(0, -1).join('-');
return gene;
}
/** Get name of transcript currently selected in menu */
function getSelectedStructure(ideo, offset=0) {
let selectedIndex, structureName;
const menu = document.querySelector('#_ideoGeneStructureMenu');
if (!menu) {
const svg = document.querySelector('._ideoGeneStructure');
if (!svg) return null; // No gene structure is available, e.g. for miRNA
structureName = svg.getAttribute('data-ideo-gene-structure-name');
selectedIndex = 0;
} else {
const numOptions = menu.options.length;
const baseIndex = menu.selectedIndex;
selectedIndex = baseIndex + offset;
if (selectedIndex >= numOptions) {
selectedIndex = 0;
} else if (selectedIndex < 0) {
selectedIndex = numOptions - 1;
}
structureName = menu.options[selectedIndex].value;
}
const gene = getGeneFromStructureName(structureName);
const geneStructure =
Ideogram.geneStructureCache[gene].find(gs => gs.name === structureName);
return [geneStructure, selectedIndex];
}
/**
* Add event listeners to the transcript menu:
* - On click, block upstream listeners from closing tooltip
* - On change, write newly selected gene structure
*/
function addMenuListeners(ideo) {
const menuId = '_ideoGeneStructureMenu';
const container = document.querySelector('._ideoGeneStructureContainer');
// Don't search this gene if clicking to expand the menu
container.addEventListener('click', async (event) => {
if (event.target.id === menuId) {
event.stopPropagation();
}
let svgMaybe = event.target;
if (svgMaybe.parentElement.tagName === 'svg') {
svgMaybe = svgMaybe.parentElement;
};
// Go to next transcript on clicking down arrow icon, previous on up
if (Array.from(svgMaybe.classList).includes('_ideoMenuArrow')) {
const menuArrow = svgMaybe;
const direction = menuArrow.getAttribute('data-dir');
const offset = direction === 'down' ? 1 : -1;
await updateGeneStructure(ideo, offset);
event.stopPropagation();
}
});
// // Go to next transcript on pressing down arrow key, previous on up
// document.addEventListener('keydown', (event) => {
// const key = event.key;
// if (['ArrowDown', 'ArrowUp'].includes(key)) {
// const offset = key === 'ArrowDown' ? 1 : -1;
// updateGeneStructure(ideo, offset);
// }
// });
}
function toggleSpliceByKeyboard(event) {
if (event.key === 's') {
const spliceToggle = document.querySelector('._ideoSpliceToggle input');
if (!spliceToggle) return;
const subpartText = document.querySelector('#_ideoSubpartText');
if (subpartText) subpartText.innerHTML = ' ';
spliceToggle.dispatchEvent(new MouseEvent('click'));
}
}
/** Listen for keydown and click on / change to splice toggle input */
function addSpliceToggleListeners(ideo) {
document.addEventListener('keydown', toggleSpliceByKeyboard);
const container = document.querySelector('._ideoGeneStructureContainer');
const toggler = document.querySelector('._ideoSpliceToggle');
if (!container) return;
toggler.addEventListener('change', async (event) => {
toggleSplice(ideo);
addHoverListeners(ideo);
event.stopPropagation();
});
}
/** Helper for keyboard navigation */
function nextIsOutOfSubpartBounds(i, subparts, key) {
const isLeft = key === 'left';
return (
i === 0 && isLeft ||
i === subparts.length - 1 && !isLeft
);
}
/**
* Swap genome-ordered subparts so they render with expected visual layering
*
* SVG elements render last-on-top, which often hides subparts UTRs at the
* ends of transcripts when they're genomically ordered, as they are in e.g.
* Ensembl.
*
* See also: swapUTRsBack
*/
function swapUTRsForward(subparts, isPositiveStrand) {
const swappedSubparts = subparts.slice();
// Account for edge case in certain nonsense-mediated-decay transcripts
const utr = isPositiveStrand ? utr3 : utr5;
const hasUtr = subparts.some(subpart => subpart[0] === utr);
subparts.forEach((subpart, i) => {
if (i === 0) return;
const prevSubpart = subparts[i - 1];
const prevIsUtr3 = prevSubpart[0] === utr3;
const prevIsUtr5 = prevSubpart[0] === utr5;
const isExon = subpart[0] === 'exon';
if (
isExon && hasUtr && (
(
!isPositiveStrand && prevIsUtr3 ||
isPositiveStrand && prevIsUtr5
) && (
// Account for splice toggle in multi-part UTRs, as in e.g.
// canonicals for FAM111B and SCARB1, and alternative MAOA-204
subpart[1] !== prevSubpart[1] + prevSubpart[2] - 1 &&
prevSubpart[2] !== 2 // Handle canonicals for RAD51 and RAD51B
)
)
) {
swappedSubparts[i] = prevSubpart;
swappedSubparts[i - 1] = subpart;
}
});
return swappedSubparts;
}
window.swapUTRsForward = swapUTRsForward;
function is(element, cls) {
return element.classList.contains(cls);
}
// isExon && (
// !isPositiveStrand && prevIsUtr3 ||
// isPositiveStrand && prevIsUtr5
// )
function shouldSwapBackDOM(subpart, nextSubpart, isPositiveStrand, hasUtr) {
const utr5 = 'five-prime-utr';
const utr3 = 'three-prime-utr';
const nextIsUtr5 = is(nextSubpart, utr5);
const nextIsUtr3 = is(nextSubpart, utr3);
const isNotUtr3 = !is(subpart, utr3);
const isNotUtr5 = !is(subpart, utr5);
return (
hasUtr && (
!isPositiveStrand && nextIsUtr3 && isNotUtr3 ||
isPositiveStrand && nextIsUtr5 && isNotUtr5
)
);
}
function shouldSwapBackData(subpart, nextSubpart, isPositiveStrand, hasUtr) {
const nextIsUtr5 = nextSubpart[0] === utr5;
const nextIsUtr3 = nextSubpart[0] === utr3;
const isNotUtr3 = subpart[0] !== utr3;
const isNotUtr5 = subpart[0] !== utr5;
return (
hasUtr && (
!isPositiveStrand && nextIsUtr3 && isNotUtr3 ||
isPositiveStrand && nextIsUtr5 && isNotUtr5
)
);
}
/**
* Restore SVG subparts to genome order, for proper keyboard navigation
*
* See also: swapUTRsForward
*/
function swapUTRsBack(subparts, isPositiveStrand) {
const swappedSubparts = subparts.slice();
const isRaw = Array.isArray(subparts[0]);
// Account for edge case in animating exon splice for
// nonsense-mediated-decay transcripts that lack an annotated UTR,
// e.g. SREBF-204
let hasUtr;
if (isRaw) {
const utr = isPositiveStrand ? utr3 : utr5;
hasUtr = subparts.some(subpart => subpart[0] === utr);
} else {
const utr5 = 'five-prime-utr';
const utr3 = 'three-prime-utr';
const utr = isPositiveStrand ? utr3 : utr5;
hasUtr = subparts.some(subpart => is(subpart, utr));
}
subparts.forEach((subpart, i) => {
if (i === swappedSubparts.length - 1) return;
const nextSubpart = subparts[i + 1];
const shouldSwapBackFn = isRaw ? shouldSwapBackData : shouldSwapBackDOM;
const shouldSwapBack =
shouldSwapBackFn(subpart, nextSubpart, isPositiveStrand, hasUtr);
if (shouldSwapBack) {
swappedSubparts.splice(i, 1, nextSubpart);
swappedSubparts.splice(i + 1, 1, subpart);
}
});
return swappedSubparts;
}
/**
* Remove any hover stroke outlines, for subpart highlight edge cases like
* mouseenter in subpart A while distant subpart B is navigated
*/
function removeHighlights() {
const cls = '_ideoHoveredSubpart';
const hovereds = document.querySelectorAll(`.${cls}`);
hovereds.forEach(el => el.classList.remove(cls));
}
/** Go to previous subpart on left arrow; next on right */
function navigateSubparts(event) {
const domSubparts = Array.from(document.querySelectorAll('rect.subpart'));
const structure = document.querySelector('._ideoGeneStructure');
const strand = structure.getAttribute('data-ideo-strand');
const isPositiveStrand = strand === '+';
const subparts = swapUTRsBack(domSubparts, isPositiveStrand);
if (subparts.length === 0) return; // E.g. paralog neighborhoods, lncRNA
const cls = '_ideoHoveredSubpart';
const subpart = document.querySelector(`.${cls}`);
if (!subpart) {
// Accounts for edge case when changing transcripts via menu
event.stopPropagation();
event.preventDefault();
return;
}
let i;
subparts.forEach((el, index) => {
if (el.classList.contains(cls)) {
i = index;
}
});
const options = {view: window, bubbles: false, cancelable: true};
const mouseEnter = new MouseEvent('mouseenter', options);
const mouseLeave = new MouseEvent('mouseleave', options);
// Account for strand, so left key always goes left; right always right
const left = isPositiveStrand ? 'ArrowLeft' : 'ArrowRight';
const right = isPositiveStrand ? 'ArrowRight' : 'ArrowLeft';
let key;
if (event.key === left) {
key = 'left';
} else if (event.key === right) {
key = 'right';
}
// Don't fall off the end
if (
typeof key === 'undefined' ||
nextIsOutOfSubpartBounds(i, subparts, key)
) {
event.stopPropagation();
event.preventDefault();
return;
}
removeHighlights();
const alt = event.altKey;
const meta = event.metaKey;
// Jump forward or back by 1, 10, or all subparts
if (event.key === left) { // Jump back
subpart.dispatchEvent(mouseLeave);
let index = i - 1;
if (alt) index = i - 10 < 0 ? 0 : i - 10;
if (meta) index = 0;
const prevSubpart = subparts[index];
prevSubpart.dispatchEvent(mouseEnter);
} else if (event.key === right) { // Jump forward
subpart.dispatchEvent(mouseLeave);
const last = subparts.length - 1;
let index = i + 1;
if (alt) index = i + 10 > last ? last : i + 10;
if (meta) index = last;
const nextSubpart = subparts[index];
nextSubpart.dispatchEvent(mouseEnter);
}
event.stopPropagation();
event.preventDefault();
}
function getMenuContainer() {
return document.querySelector('#_ideoGeneStructureMenuContainer');
}
function updateFooter(content, ideo) {
const footer = getFooter();
footer.innerHTML = content;
initTippy(ideo);
}
function addSubpartHoverListener(subpartDOM, ideo) {
const subpart = subpartDOM;
// On hovering over subpart, highlight it and show details
subpart.addEventListener('mouseenter', event => {
removeHighlights();
// Highlight hovered subpart, adding an aura around it
event.target.classList.add('_ideoHoveredSubpart');
// Show details
const footer = getFooter();
ideo.originalTooltipFooter = footer.innerHTML;
const subpartText = subpart.getAttribute('data-subpart');
const trimmedFoot = footer.innerHTML.replace(' ', '');
const style = 'style="margin-bottom: -10px; max-width: 260px;"';
const id = 'id="_ideoSubpartText"';
const content = `<div ${id} ${style}">${subpartText}</div>${trimmedFoot}`;
updateFooter(content, ideo);
const menuContainer = getMenuContainer();
if (menuContainer) menuContainer.style.marginTop = '';
});
// On hovering out, de-highlight and hide details
subpart.addEventListener('mouseleave', event => {
event.target.classList.remove('_ideoHoveredSubpart');
updateFooter(ideo.originalTooltipFooter, ideo);
const menuContainer = getMenuContainer();
if (menuContainer) menuContainer.style.marginTop = '4px';
});
}
/** Did the mouse event occur inside the tooltip area? */
function isMouseEventInTooltip(event) {
const tooltip = document.querySelector('._ideogramTooltip');
const box = tooltip.getBoundingClientRect();
const x = event.screenX;
const y = event.screenY;
const inTooltip =
(x > box.left && x < box.right && y > box.top && y < box.bottom);
return inTooltip;
}
/**
* Add handlers for hover events in transcript container and beneath, e.g.:
*
* - Show transcript details on hovering near transcript
* - Show subpart (i.e. exon, 3'-UTR, 5'-UTR) details on hovering over subpart
* - Highlight subpart on hovering over subpart
* - Navigate to previous or next subpart on pressing left or right arrow keys
*/
function addHoverListeners(ideo) {
const subparts = document.querySelectorAll('rect.subpart');
if (subparts.length === 0) return; // E.g. paralog neighborhoods, lncRNA
ideo.subparts = subparts;
const container = document.querySelector('._ideoGeneStructureContainer');
container.addEventListener('mouseenter', () => {
document.addEventListener('keydown', navigateSubparts);
if (ideo.addedMenuListeners) return;
ideo.addedMenuListeners = true;
writeFooter(container);
// Listen for change of selected option in transcript menu
const tooltip = document.querySelector('._ideogramTooltip');
tooltip.addEventListener('change', async () => {
await updateGeneStructure(ideo);
// Without this, selecting a new transcript will close the tooltip if
// the selected <option> screen position is outside the tooltip (as
// is often the case in genes with many transcripts, like TP53).
ideo.oneTimeDelayTooltipHideMs = 2000; // wait 2.0 s instead of 0.25 s
});
if (Ideogram.tissueCache) {
const tooltipFooter = document.querySelector('._ideoTooltipFooter');
tooltipFooter.style.display = 'none';
}
});
container.addEventListener('mouseleave', (event) => {
ideo.oneTimeDelayTooltipHideMs = 2000; // See "Without this..." note above
if (Ideogram.tissueCache) {
const tooltipFooter = document.querySelector('._ideoTooltipFooter');
tooltipFooter.style.display = '';
}
const inTooltip = isMouseEventInTooltip(event);
if (inTooltip === true) {
// Only remove transcript footer if `mouseleave` event is from footer to
// another part of the tooltip. If `mouseleave`-ing from footer to
// *outside* the tooltip -- e.g. when selecting a new transcript as in
// the "Without this..." scenario noted above -- then do not remove the
// footer. This lets users always see the details in the footer for the
// transcript they just selected, rather than having the details
// frustratingly disappear immediately upon transcript selection.
updateFooter(hoverTip, ideo);
}
ideo.addedMenuListeners = false;
document.removeEventListener('keydown', navigateSubparts);
});
if (ideo.addedSubpartListeners) return;
ideo.addedSubpartListeners = true;
subparts.forEach(subpart => {
addSubpartHoverListener(subpart, ideo);
});
}
function writeStrandInFooter(ideo) {
const strand = getSelectedStructure(ideo)[0].strand;
if (strand === '+') return; // Don't remark if strand is the default
const tooltipFooter = document.querySelector('._ideoTooltipFooter');
tooltipFooter.innerText =
tooltipFooter.innerText.replace(')', `, ${strand})`);
}
function getTippyConfig(fallbackPlacements) {
return {
theme: 'light-border',
popperOptions: { // Docs: https://atomiks.github.io/tippyjs/v6/all-props/#popperoptions
modifiers: [ // Docs: https://popper.js.org/docs/v2/modifiers
{
name: 'flip',
options: {
fallbackPlacements // Defined via argument to this function
}
}
]
},
onShow: function() {
// Ensure only 1 tippy tooltip is displayed at a time
document.querySelectorAll('[data-tippy-root]')
.forEach(tippyNode => tippyNode.remove());
}
};
}
function initTippy(ideo) {
const toggle = getTippyConfig(['top-start', 'top']);
ideo.tippy = tippy('._ideoSpliceToggle[data-tippy-content]', toggle);
const arrow = getTippyConfig(['bottom']);
arrow.popperOptions.modifiers.push({
name: 'offset',
options: {
offset: [-5, 20]
}
});
const updownTips = tippy('._ideoMenuArrow[data-tippy-content]', arrow);
ideo.tippy = ideo.tippy.concat(updownTips);
}
export function addGeneStructureListeners(ideo) {
const structure = getSelectedStructure(ideo);
if (structure === null) return; // Bail for e.g. miRNA
addSpliceToggleListeners(ideo);
addHoverListeners(ideo);
addMenuListeners(ideo);
writeStrandInFooter(ideo);
initTippy(ideo);
}
function getSpliceToggleHoverTitle(spliceExons) {
return spliceExons ? 'Unsplice exons (s)' : 'Splice exons (s)';
}
function getSpliceToggle(ideo) {
const spliceExons = Ideogram.spliceExons;
const modifier = spliceExons ? '' : 'pre-';
const cls = `class="_ideoSpliceToggle ${modifier}mRNA"`;
const checked = spliceExons ? 'checked' : '';
const text = getSpliceToggleHoverTitle(spliceExons);
const tPlace = 'data-tippy-placement="right"';
const title = `data-tippy-content="${text}" ${tPlace}`;
const inputAttrs =
`type="checkbox" ${checked} ` +
`style="display: none;"`;
const style =
'style="position: relative; top: -10px; ' +
'user-select: none; ' + // Prevent distracting highlight on quick toggle
'float: right; cursor: pointer; font-size: 16px; ' +
'padding: 2px 4px; border: 1px solid #CCC; border-radius: 3px;"';
const attrs = `${cls} ${style} ${title}`;
// Scissors icon
const label = `<label ${attrs}><input ${inputAttrs} />✂</label>`;
return label;
}
/** Splice exons in transcript, removing introns; add positions */
function spliceOut(subparts) {
const splicedSubparts = [];
let prevEnd = 0;
let prevStart = 0;
for (let i = 0; i < subparts.length; i++) {
const subpart = subparts[i];
const [subpartType, start, length] = subpart;
const isSpliceOverlap = start === prevStart;
let prevRawStart, prevRawLength;
if (i > 0) {
[, prevRawStart, prevRawLength] = subparts[i - 1];
}
// e.g. 5'-UTRs of OXTR
const isOtherOverlap = i > 0 && start === prevRawStart;
// e.g. 3'-UTR of LDLR, or 3'-UTR of CD44
const isOther3UTROverlap = i > 0 && start <= prevRawStart + prevRawLength;
let splicedStart;
if (isSpliceOverlap) {
splicedStart = start;
} else if (isOtherOverlap) {
// e.g. 5'-UTRs of OXTR
splicedStart = prevStart;
} else if (isOther3UTROverlap) {
splicedStart = prevStart + prevRawLength - length;
} else {
splicedStart = prevEnd;
}
const splicedEnd = splicedStart + length;
const splicedSubpart = [
subpartType, splicedStart, length + 1, start
];
splicedSubparts.push(splicedSubpart);
prevEnd = splicedEnd;
prevStart = splicedStart;
}
const splicedPositionedSubparts = addPositions(splicedSubparts);
return splicedPositionedSubparts;
}
/** Insert introns to transcript; add positions */
function spliceIn(subparts) {
const splicedSubparts = [];
let prevEnd = 0;
for (let i = 0; i < subparts.length; i++) {
const subpart = subparts[i];
const [start, length] = subpart.slice(1);
if (start > prevEnd) {
const intronStart = prevEnd;
const intronLength = start - prevEnd - 1;
splicedSubparts.push(['intron', intronStart, intronLength]);
}
prevEnd = start + length;
splicedSubparts.push(subpart);
}
const splicedPositionedSubparts = addPositions(splicedSubparts);
return splicedPositionedSubparts;
}
function getSpliceStateText(spliceExons, isCanonical=true) {
let modifier = '';
let suffix = ' and protein';
let titleMod = 'without';
if (!spliceExons) {
modifier = 'pre-';
suffix = '';
titleMod = 'with';
}
const canonOrAlt = isCanonical ? 'Canonical' : 'Alternative';
const title = `${canonOrAlt} transcript per Ensembl, ${titleMod} introns`;
const name = `${canonOrAlt} ${modifier}mRNA ${suffix}`;
return {title, name};
}
/** Draw introns in initial splice toggle from mRNA to pre-mRNA */
function drawIntrons(prelimSubparts, matureSubparts, ideo) {
// Hypothetical example data, in shorthand
// pres = [u5_1, e1, i1, e2, i2, e3, i3, e4, i4, e5, i5, e6, u3_1]
// mats = [u5_1, e1, e2, e3, e4, e5, e6, u3_1]
let numInserted = 0;
const subpartEls = document.querySelectorAll('.subpart');
prelimSubparts.forEach((prelimSubpart, i) => {
const matureIndex = i - numInserted;
const matureSubpart = matureSubparts[matureIndex];
if (matureSubpart[0] !== prelimSubpart[0]) {
const summary = prelimSubpart.slice(-1)[0].summary;
const otherAttrs = `y="${y}" height="20" fill="#FFFFFF00" ${summary}`;
const intronRect =
`<rect class="subpart intron" ${otherAttrs} />`;
subpartEls[matureIndex].insertAdjacentHTML('beforebegin', intronRect);
numInserted += 1;
}
});
document.querySelectorAll('.intron').forEach(subpartDOM => {
addSubpartHoverListener(subpartDOM, ideo);
});
}
function updateHeader(spliceExons, isCanonical) {
// Update title for gene structure diagram
const nameDOM =
document.querySelector('._ideoGeneStructureContainerName');
const toggleDOM = document.querySelector('._ideoSpliceToggle');
if (nameDOM && toggleDOM) {
[nameDOM, toggleDOM].forEach(el => el.classList.remove('pre-mRNA'));
if (!spliceExons) {
[nameDOM, toggleDOM].forEach(el => el.classList.add('pre-mRNA'));
}
const {title, name} = getSpliceStateText(spliceExons, isCanonical);
nameDOM.textContent = name;
nameDOM.title = title;
}
}
async function toggleSplice(ideo) {
Ideogram.spliceExons = !Ideogram.spliceExons;
const spliceExons = Ideogram.spliceExons;
const [geneStructure, selectedIndex] = getSelectedStructure(ideo);
const isCanonical = (selectedIndex === 0);
const svgResult = await getSvg(geneStructure, ideo, spliceExons);
const [, prelimSubparts, matureSubparts] = svgResult;
const proteinSvg = document.querySelector('#_ideoProtein');
if (proteinSvg && !spliceExons) proteinSvg.style.display = 'none';
const addedIntrons = document.querySelectorAll('.intron').length > 0;
if (!spliceExons && !addedIntrons) {
drawIntrons(prelimSubparts, matureSubparts, ideo);
} else {
document.querySelectorAll('.intron').forEach(el => el.remove());
}
document.querySelectorAll('.subpart-line.rna').forEach(el => el.remove());
const subparts = spliceExons ? matureSubparts : prelimSubparts;
console.log('in toggleSplice, subparts', subparts)
d3.select('._ideoGeneStructure').selectAll('.subpart')
.data(subparts)
.transition()
.duration(750)
.attr('x', (d, i) => subparts[i].slice(-1)[0].x)
.attr('width', (d, i) => subparts[i].slice(-1)[0].width)
.on('end', async (d, i) => {
console.log('in end')
if (i !== subparts.length - 1) return;
if (proteinSvg && spliceExons) proteinSvg.style.display = '';
// Restore subpart boundary lines
const subpartDOMs = document.querySelectorAll('.subpart:not(.domain)');
subpartDOMs.forEach((subpartDOM, i) => {
const subpart = subparts[i];
const line = getSubpartBorderLine(subpart);
subpartDOM.insertAdjacentHTML('afterend', line);
});
updateHeader(spliceExons, isCanonical);
const tlbpDOM = document.querySelector('#_ideoTranscriptLengthBp');
if (!tlbpDOM) return;
const transcriptLengthBp = getTranscriptLengthBp(subparts, spliceExons);
const prettyLength = transcriptLengthBp.toLocaleString();
tlbpDOM.innerText = `${prettyLength} bp`;
const variantSvg = await getVariantsSvg(geneStructure, subparts, ideo);
console.log('toggled variantSvg.length', variantSvg.length)
document.querySelector('.variantsDiagrams').innerHTML = variantSvg;
ideo.tippy[0].show();
});
}
function getTranscriptLengthBp(subparts, spliceExons=false) {
const exons = subparts.filter(sp => sp[0] === 'exon');
if (spliceExons) subparts = exons;
const lastSubpart = subparts.slice(-1)[0];
const lastStart = lastSubpart[1];
const lastLength = lastSubpart[2];
const exonFill = spliceExons ? exons.length - 1 : 0;
const transcriptLengthBp = lastStart + lastLength + exonFill;
return transcriptLengthBp;
}
export function getBpPerPx(subparts, projectedFeatures=null) {
const transcriptLengthPx = 250;
const totalLengthBp = getTranscriptLengthBp(subparts);
const isProtein = projectedFeatures;
const factor = isProtein ? 3 : 1; // 3 nt per aa
const bpPerPx = (totalLengthBp / transcriptLengthPx) / factor;
return bpPerPx;
}
/** Merge feature type, pixel-x position, and pixel width to each feature */
export function addPositions(subparts, projectedFeatures=null) {
const bpPerPx = getBpPerPx(subparts, projectedFeatures);
const features = projectedFeatures ?? subparts;
for (let i = 0; i < features.length; i++) {
const feature = features[i];
if (typeof feature.slice(-1)[0] === 'object') continue;
// Define subpart position, tooltip footer
const lengthBp = feature[2];
const x = feature[1] / bpPerPx;
const width = lengthBp / bpPerPx;
const type = feature[0];
features[i].push({type, x, width});
}
return features;
}
/** Get text shown below diagram upon hovering over an exon, intron, or UTR */
function getSubpartSummary(subpartType, total, index, strand, lengthBp) {
if (strand === '-') index = total - index + 1;
const numOfTotal = total > 1 ? `${index} of ${total} ` : '';
const prettyType = subpartType[0].toUpperCase() + subpartType.slice(1);
const prettyLength = lengthBp.toLocaleString();
const html = `${prettyType} ${numOfTotal}${pipe} ${prettyLength} bp`;
const summary = `data-subpart="${html}"`;
return summary;
}
/** Get subtle line to visually demarcate subpart boundary */
function getSubpartBorderLine(subpart) {
const subpartType = subpart[0];
// Define subpart border
const x = subpart.slice(-1)[0].x;
const height = heights[subpartType];
const lineHeight = y + height;
const lineStroke = `stroke="${lineColors[subpartType]}"`;
const lineAttrs = // "";
`x1="${x}" x2="${x}" y1="${y}" y2="${lineHeight}" ${lineStroke}`;
return `<line class="subpart-line rna" ${lineAttrs} />`;
}
// function getSvgList(gene, ideo, spliceExons=false) {
// if (
// 'geneStructureCache' in Ideogram === false ||
// gene in Ideogram.geneStructureCache === false
// ) {
// return [null];
// }
// const svgList = Ideogram.geneStructureCache[gene].map(geneStructure => {
// return getSvg(geneStructure, ideo, spliceExons);
// });
// return svgList;
// }
/** Get SVG, and prelimnary and mature subparts for given gene structure */
async function getSvg(geneStructure, ideo, spliceExons=false) {
const strand = geneStructure.strand;
const rawSubparts = geneStructure.subparts;
let subparts;
// Add pixel coordinates to subparts, and pre-mRNA and mRNA sets
let prelimSubparts = spliceIn(rawSubparts);
let matureSubparts = spliceOut(rawSubparts);
if (spliceExons) {
subparts = matureSubparts;
} else {
subparts = prelimSubparts;
}
const spliceToggle = document.querySelector('._ideoSpliceToggle');
if (spliceToggle) {
const title = getSpliceToggleHoverTitle(spliceExons);
spliceToggle.setAttribute('data-tippy-placement', 'right');
// spliceToggle.setAttribute('data-tippy-placement-fallback', 'top-end');
spliceToggle.setAttribute('data-tippy-content', title);
initTippy(ideo);
}
const featureLengthPx = 250 - 2; // Snip to avoid overextending
const intronHeight = 1;
const intronColor = 'black';
const geneStructureArray = [];
const intronPosAttrs =
`x="0" width="${featureLengthPx}" y="${y + 10}" height="${intronHeight}"`;
const intronRect =
`<rect fill="black" ${intronPosAttrs}/>`;
geneStructureArray.push(intronRect);
// Set up counters for e.g. "Exon 2 of 4" ("<subpart> <num> of <total>")
const indexBySubpart = {
"5'-UTR": 0,
'exon': 0,
'intron': 0,
"3'-UTR": 0
};
const totalBySubpart = {
"5'-UTR": 0,
'exon': 0,
'intron': 0,
"3'-UTR": 0
};
const isPositiveStrand = strand === '+';
subparts = swapUTRsForward(subparts, isPositiveStrand);
prelimSubparts = swapUTRsForward(prelimSubparts, isPositiveStrand);
matureSubparts = swapUTRsForward(matureSubparts, isPositiveStrand);
// Container for positional data: x, width
// const prelimPositions = getPositions(prelimSubparts);
// const maturePositions = getPositions(matureSubparts);
// matureSubparts = matureSubparts.filter(p => p[0] !== 'intron');
// const positions = spliceExons ? trimmedMatures : prelimPositions;
// Get counts for e.g. "4" in "Exon 2 of 4"
for (let i = 0; i < subparts.length; i++) {
const subpart = subparts[i];
const subpartType = subpart[0];
if (subpartType in totalBySubpart) {
totalBySubpart[subpartType] += 1;
}
}
const structureName = geneStructure.name;
const gene = getGeneFromStructureName(structureName);
for (let i = 0; i < subparts.length; i++) {
const subpart = subparts[i];
// const position = positions[i];
const subpartType = subpart[0];
let color = intronColor;
if (subpartType in colors) {
color = colors[subpartType];
}
const height = heights[subpartType];
// Define subpart position, tooltip footer
const lengthBp = subpart[2];
const x = subpart.slice(-1)[0].x;
const width = subpart.slice(-1)[0].width;
const pos = `x="${x}" width="${width}" y="${y}" height="${height}"`;
const cls = `class="subpart ${subpartClasses[subpartType]}" `;
// TODO: Handle introns better, refine CDS vs. UTR in exons
const total = totalBySubpart[subpartType];
indexBySubpart[subpartType] += 1;
const subpartIndex = indexBySubpart[subpartType];
const summary =
getSubpartSummary(subpartType, total, subpartIndex, strand, lengthBp);
if (!spliceExons) {
// console.log('prelimSubparts[i]', prelimSubparts[i])
prelimSubparts[i].slice(-1)[0].summary = summary;
} else if (subpartType !== 'intron') {
matureSubparts[i].slice(-1)[0].summary = summary;
}
const subpartSvg = (
`<rect ${cls} rx="1.5" fill="${color}" ${pos} ${summary}/>` +
getSubpartBorderLine(subpart)
);
geneStructureArray.push(subpartSvg);
}
const sharedStyle =
'position: relative; width: 274px; margin: auto;';
let transform = `style="${sharedStyle} left: 10px;"`;
if (strand === '-') {
transform =
'transform="scale(-1 1)" ' +
`style="${sharedStyle} left: -10px;"`;
}
const menu = getMenu(gene, ideo, structureName).replaceAll('"', '\'');
const hasTopology = getHasTopology(gene, ideo);
const [proteinSvg, proteinLengthAa] =
getProtein(structureName, subparts, isPositiveStrand, hasTopology, ideo);
const variantSvg = await getVariantsSvg(geneStructure, subparts, ideo);
const transcriptLengthBp = getTranscriptLengthBp(subparts, spliceExons);
const prettyLength = transcriptLengthBp.toLocaleString();
const footerDetails = [
`${totalBySubpart['exon']} exons`,
`<span id='_ideoTranscriptLengthBp'>${prettyLength} bp</span> `
];
// Note protein length in amino acids (aa)
// TODO: This is a no-op; see note about phase in protein.js
if (proteinLengthAa) {
const prettyLengthAa = proteinLengthAa.toLocaleString();
footerDetails.push(
`<span id='_ideoProteinLengthAa'>${prettyLengthAa} aa</span> `
);
}
// Note if transcript is unusual type, e.g. nonsense mediated decay (NMD)
const biotypeText = geneStructure.biotype.replace(/_/g, ' ');
if (biotypeText !== 'protein coding') {
footerDetails.push(biotypeText);
}
const footerData = menu + footerDetails.join(` ${pipe} `);
let svgHeight = proteinSvg === '' ? 30 : 50;
if (hasTopology) svgHeight = 60;
let translate = '';
if (variantSvg) {
const varHeight = 18;
svgHeight += varHeight;
translate = `transform="translate(0, ${varHeight})"`;
}
const geneStructureSvg =
`<svg class="_ideoGeneStructure" ` +
`data-ideo-gene-structure-name="${structureName}" ` +
`data-ideo-strand="${strand}" data-ideo-footer="${footerData}" ` +
`width="${(featureLengthPx + 20)}" height="${svgHeight}" ${transform}` +
`>` +
`<g class="rnaProteinDiagrams" ${translate}>` +
geneStructureArray.join('') +
proteinSvg +
`</g>` +
`<g class="variantsDiagrams">` +
variantSvg +
`</g>` +
'</svg>';
return [geneStructureSvg, prelimSubparts, matureSubparts];
}
/** Get down and up arrows for one-click, carousel-like navigation for menu */
function getMenuArrows() {
// Get attributes
const style =
'width: 12px; height: 12px; cursor: pointer;' +
'user-select: none;'; // Prevent distracting highlight on quick clicks
const downStyle = `style="${style}; margin-left: 5px;"`;
const upStyle = `style="${style}; margin-left: 2px;"`;
const cls = 'class="_ideoMenuArrow"';
const tippyPlace = 'data-tippy-placement="bottom-start"';
const downContent = 'Next transcript';
const downTippy = `data-tippy-content="${downContent}" ${tippyPlace}`;
const upContent = 'Previous transcript';
const upTippy = `data-tippy-content="${upContent}" ${tippyPlace}`;
const downAttrs = `${downStyle} ${cls} data-dir="down" ${downTippy}`;
const upAttrs = `${upStyle} ${cls} data-dir="up" ${upTippy}`;
// Get SVG polygon elements
const down = getIcon(
{shape: 'triangle', color: '#888'},
{config: {orientation: 'down'}}
);
const up = getIcon({shape: 'triangle', color: '#888'}, {config: ''});
const downArrow = `<svg ${downAttrs}>${down}</svg>`;
const upArrow = `<svg ${upAttrs}>${up}</svg>`;
const menuArrows = downArrow + upArrow;
return menuArrows;
}
/** Get menu for all transcripts for this gene */
function getMenu(gene, ideo, selectedName) {
const containerId = '_ideoGeneStructureMenuContainer';
const style = 'margin-bottom: 4px; margin-top: 4px; clear: both;';
const structures = Ideogram.geneStructureCache[gene];
if (structures.length === 1) {
const name = structures[0].name;
const line =
`<div id="${containerId}" style="${style}">Transcript: ${name}</div>`;
return line;
}
const options = structures.map(structure => {
const name = structure.name;
let selected = '';
if (selectedName && selectedName === structure.name) {
selected = ' selected';
}
return `<option value="${name}" ${selected}>${name}</option>`;
}).join('');
const id = '_ideoGeneStructureMenu';
const menuArrows = getMenuArrows();
const menu =
`<div id="${containerId}" style="${style}">` +
`<label for="${id}">Transcript:</label> ` +
`<select id="${id}" name="${id}">${options}</select>` +
menuArrows +
`</div>`;
return menu;
}
export async function getGeneStructureHtml(annot, ideo, isParalogNeighborhood) {
let geneStructureHtml = '';
const gene = annot.name;
if (
ideo.config.showGeneStructureInTooltip && !isParalogNeighborhood &&
!(
'geneStructureCache' in Ideogram === false ||
gene in Ideogram.geneStructureCache === false
)
) {
ideo.addedSubpartListeners = false;
if ('spliceExons' in Ideogram === false) Ideogram.spliceExons = true;
const spliceExons = Ideogram.spliceExons;
const structure = Ideogram.geneStructureCache[gene][0];
const svgResults = await getSvg(structure, ideo, spliceExons);
const geneStructureSvg = svgResults[0];
const cls = 'class="_ideoGeneStructureContainer"';
const toggle = getSpliceToggle(ideo);
const rnaClass = spliceExons ? '' : ' pre-mRNA';
const spanClass = `class="_ideoGeneStructureContainerName${rnaClass}"`;
const {name, title} = getSpliceStateText(spliceExons);
const spanAttrs = `${spanClass} title="${title}"`;
const divAttrs = 'class="_ideoGeneStructureContainerHead"';
const containerStyle = '';
geneStructureHtml =
css +
`<div ${cls}>` +
`<div ${divAttrs}><span ${spanAttrs}>${name}</span>${toggle}</div>` +
`<span class="_ideoGeneStructureSvgContainer" ${containerStyle}>` +
geneStructureSvg +
`</span>` +
`<div class="_ideoGeneStructureFooter">` +
hoverTip +
`</div>` +
`</div>`;
}
return geneStructureHtml;
}