diff --git a/examples/vanilla/gene-leads.html b/examples/vanilla/gene-leads.html index e6223633..0e74d9a9 100644 --- a/examples/vanilla/gene-leads.html +++ b/examples/vanilla/gene-leads.html @@ -242,6 +242,7 @@

Gene LeadsEnrich your gene search

organism: organism, container: '#ideogram-container', // fontFamily: "'Montserrat', sans-serif", + showVariantInTooltip: plotGeneFromUrl, onLoad: plotGeneFromUrl, onPlotFoundGenes: reportFoundGenes, onHoverLegend: reportLegendMetrics, diff --git a/karma.conf.js b/karma.conf.js index 3b4067c7..09d61a4c 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -16,10 +16,10 @@ module.exports = function(config) { // list of files / patterns to load in the browser files: [ 'src/js/index.js', - // 'test/offline/**.test.js', - // 'test/online/**.test.js', + 'test/offline/**.test.js', + 'test/online/**.test.js', // 'test/online/related-genes.test.js', - 'test/offline/gene-structure.test.js', + // 'test/offline/gene-structure.test.js', // 'test/offline/tissue.test.js', {pattern: 'dist/data/**', watched: false, included: false, served: true, nocache: false} ], diff --git a/package-lock.json b/package-lock.json index ece2f589..0a338592 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "d3-scale": "^4.0.2", "fast-kde": "0.2.1", "fflate": "^0.7.3", + "snarkdown": "^2.0.0", "tippy.js": "6.3.7", "workbox-range-requests": "7.0.0" }, @@ -9268,6 +9269,11 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/snarkdown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/snarkdown/-/snarkdown-2.0.0.tgz", + "integrity": "sha512-MgL/7k/AZdXCTJiNgrO7chgDqaB9FGM/1Tvlcenenb7div6obaDATzs16JhFyHHBGodHT3B7RzRc5qk8pFhg3A==" + }, "node_modules/socket.io": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.6.1.tgz", @@ -18001,6 +18007,11 @@ } } }, + "snarkdown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/snarkdown/-/snarkdown-2.0.0.tgz", + "integrity": "sha512-MgL/7k/AZdXCTJiNgrO7chgDqaB9FGM/1Tvlcenenb7div6obaDATzs16JhFyHHBGodHT3B7RzRc5qk8pFhg3A==" + }, "socket.io": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.6.1.tgz", diff --git a/package.json b/package.json index 32aee1a0..274bec46 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "fast-kde": "0.2.1", "fflate": "^0.7.3", "tippy.js": "6.3.7", - "workbox-range-requests": "7.0.0" + "workbox-range-requests": "7.0.0", + "snarkdown": "^2.0.0" }, "devDependencies": { "@babel/core": "^7.16.0", diff --git a/src/js/annotations/annotations.js b/src/js/annotations/annotations.js index 4acf3f1f..8195858b 100644 --- a/src/js/annotations/annotations.js +++ b/src/js/annotations/annotations.js @@ -287,9 +287,9 @@ export function applyRankCutoff(annots, cutoff, ideo) { export function setAnnotRanks(annots, ideo) { if (annots.length === 0) return annots; if ('initRank' in annots[0] === false) { - if ('geneCache' in ideo === false) return annots; + if ('geneCache' in Ideogram === false) return annots; - const ranks = ideo.geneCache.interestingNames; + const ranks = Ideogram.geneCache.interestingNames; return annots.map(annot => { if (ranks.includes(annot.name)) { diff --git a/src/js/ideogram.js b/src/js/ideogram.js index 5624f358..2afc1368 100644 --- a/src/js/ideogram.js +++ b/src/js/ideogram.js @@ -68,6 +68,15 @@ import { plotRelatedGenes, getRelatedGenesByType } from './kit/related-genes'; +import { + drawPathway as _drawPathway, + getPathwayGenes as _getPathwayGenes +} from './kit/pathway-viewer.js'; + +import { + initCaches as _initCaches +} from './init/caches/cache'; + export default class Ideogram { constructor(config) { @@ -340,4 +349,55 @@ export default class Ideogram { static initGeneLeads(config, annotsInList='all') { return _initGeneLeads(config, annotsInList); } + + /** + * Wrapper for drawing biological pathways using cached WikiPathways data + * + * @param {String} pwId WikiPathways ID, e.g. "WP5109" + * @param {String} sourceGene Symbol of source gene, e.g. "LDLR" + * @param {String} destGene Symbol of destination gene, e.g. "PCSK9" + * @param {String} outerSelector DOM selector of container, e.g. "#my-diagram" + * @param {Object} dimensions Height and width of pathway diagram + * @param {Boolean} showClose Whether to show close button + * @param {Function} geneNodeHoverFn Function to call upon hovering diagram node + */ + static drawPathway( + pwId, sourceGene, destGene, + outerSelector, + dimensions={height: 440, width: 900}, + showClose=true, + geneNodeHoverFn=undefined, + pathwayNodeClickFn=undefined + ) { + _drawPathway( + pwId, sourceGene, destGene, + outerSelector, + dimensions=dimensions, + showClose=showClose, + geneNodeHoverFn=geneNodeHoverFn, + pathwayNodeClickFn=pathwayNodeClickFn + ); + } + + /** + * Wrapper for initializing cached data + * + * @param {Object} config Includes organism, useCache, etc. + */ + static initCaches(config={ + organism: 'homo-sapiens', useCache: true, + awaitCache: true, + showGeneStructureInTooltip: true + }) { + _initCaches(config); + } + + /** + * Get list of gene names in pathway + * + * @param {Object} config Includes organism, useCache, etc. + */ + static getPathwayGenes() { + return _getPathwayGenes(); + } } diff --git a/src/js/init/caches/cache.js b/src/js/init/caches/cache.js index 2476837e..3b10cceb 100644 --- a/src/js/init/caches/cache.js +++ b/src/js/init/caches/cache.js @@ -45,9 +45,8 @@ import {parseVariantCacheIndex} from './variant-cache-worker'; * possible completely offline (i.e. a progressive web component) -- but only * once caches are populated. */ -export async function initCaches(ideo) { +export async function initCaches(config) { - const config = ideo.config; if (!config.useCache) return; const organism = config.organism; @@ -60,31 +59,35 @@ export async function initCaches(ideo) { // resolves a Promise, whereas the others return upon completing their // respective initializations. const cachePromise = Promise.all([ - cacheFactory('gene', organism, ideo, cacheDir), - cacheFactory('paralog', organism, ideo, cacheDir), - cacheFactory('interaction', organism, ideo, cacheDir), - cacheFactory('synonym', organism, ideo, cacheDir), + cacheFactory('gene', organism, config, cacheDir), + cacheFactory('paralog', organism, config, cacheDir), + cacheFactory('interaction', organism, config, cacheDir), + cacheFactory('synonym', organism, config, cacheDir), ]); if (config.showGeneStructureInTooltip) { - cacheFactory('geneStructure', organism, ideo, cacheDir); - cacheFactory('protein', organism, ideo, cacheDir); - cacheFactory('tissue', organism, ideo, cacheDir); - cacheFactory('variant', organism, ideo, cacheDir); + cacheFactory('geneStructure', organism, config, cacheDir); + cacheFactory('protein', organism, config, cacheDir); + cacheFactory('tissue', organism, config, cacheDir); + if (config.showVariantInTooltip) { + cacheFactory('variant', organism, config, cacheDir); + } } return cachePromise; } else { - cacheFactory('gene', organism, ideo, cacheDir); - cacheFactory('paralog', organism, ideo, cacheDir); - cacheFactory('interaction', organism, ideo, cacheDir); + cacheFactory('gene', organism, config, cacheDir); + cacheFactory('paralog', organism, config, cacheDir); + cacheFactory('interaction', organism, config, cacheDir); if (config.showGeneStructureInTooltip) { - cacheFactory('geneStructure', organism, ideo, cacheDir); - cacheFactory('protein', organism, ideo, cacheDir); - cacheFactory('synonym', organism, ideo, cacheDir); - cacheFactory('tissue', organism, ideo, cacheDir); - cacheFactory('variant', organism, ideo, cacheDir); + cacheFactory('geneStructure', organism, config, cacheDir); + cacheFactory('protein', organism, config, cacheDir); + cacheFactory('synonym', organism, config, cacheDir); + cacheFactory('tissue', organism, config, cacheDir); + if (config.showVariantInTooltip) { + cacheFactory('variant', organism, config, cacheDir); + } } } } @@ -140,14 +143,14 @@ const allCacheProps = { } }; -function setGeneCache(parsedCache, ideo) { +function setGeneCache(parsedCache) { const [ interestingNames, nameCaseMap, namesById, fullNamesById, idsByName, lociByName, lociById //, sortedAnnots ] = parsedCache; - ideo.geneCache = { + Ideogram.geneCache = { interestingNames, // Array ordered by general or scholarly interest nameCaseMap, // Maps of lowercase gene names to proper gene names namesById, @@ -159,46 +162,46 @@ function setGeneCache(parsedCache, ideo) { }; } -function setParalogCache(parsedCache, ideo) { +function setParalogCache(parsedCache) { const paralogsByName = parsedCache; // Array of paralog Ensembl IDs by (uppercase) gene name - ideo.paralogCache = {paralogsByName}; + Ideogram.paralogCache = {paralogsByName}; } -function setInteractionCache(parsedCache, ideo) { +function setInteractionCache(parsedCache) { const interactionsByName = parsedCache; - ideo.interactionCache = interactionsByName; + Ideogram.interactionCache = interactionsByName; } -function setGeneStructureCache(parsedCache, ideo) { +function setGeneStructureCache(parsedCache) { const featuresByGene = parsedCache; - ideo.geneStructureCache = featuresByGene; + Ideogram.geneStructureCache = featuresByGene; } -function setProteinCache(parsedCache, ideo) { - ideo.proteinCache = parsedCache; +function setProteinCache(parsedCache) { + Ideogram.proteinCache = parsedCache; } -function setSynonymCache(parsedCache, ideo) { - ideo.synonymCache = parsedCache; +function setSynonymCache(parsedCache) { + Ideogram.synonymCache = parsedCache; } -function setTissueCache(parsedCache, ideo) { - ideo.tissueCache = parsedCache; +function setTissueCache(parsedCache) { + Ideogram.tissueCache = parsedCache; } -function setVariantCache(parsedCache, ideo) { - ideo.variantCache = parsedCache; +function setVariantCache(parsedCache) { + Ideogram.variantCache = parsedCache; } -async function cacheFactory(cacheName, orgName, ideo, cacheDir=null) { +async function cacheFactory(cacheName, orgName, config, cacheDir=null) { const cacheProps = allCacheProps[cacheName]; - const debug = ideo.config.debug; + const debug = config.debug; /** - * Fetch cached gene data, transform it usefully, and set it as ideo prop - */ + * Fetch cached gene data, transform it usefully, and set it as Ideogram prop + */ const startTime = performance.now(); let perfTimes = {}; @@ -211,7 +214,6 @@ async function cacheFactory(cacheName, orgName, ideo, cacheDir=null) { // Skip initialization if cache is already populated if (Ideogram[staticProp] && Ideogram[staticProp][orgName]) { // Simplify chief use case, i.e. for single organism - ideo[staticProp] = Ideogram[staticProp][orgName]; return; } @@ -233,8 +235,8 @@ async function cacheFactory(cacheName, orgName, ideo, cacheDir=null) { // cacheWorker.postMessage(message); // cacheWorker.addEventListener('message', event => { // [parsedCache, perfTimes] = event.data; - cacheProps.fn(parsedCache, ideo, orgName); - Ideogram[staticProp][orgName] = ideo[staticProp]; + cacheProps.fn(parsedCache, orgName); + Ideogram[staticProp][orgName] = Ideogram[staticProp]; if (debug) { console.timeEnd(`${cacheName}Cache total`); diff --git a/src/js/init/caches/tissue-cache-worker.js b/src/js/init/caches/tissue-cache-worker.js index 53fe3a8b..6af399bc 100644 --- a/src/js/init/caches/tissue-cache-worker.js +++ b/src/js/init/caches/tissue-cache-worker.js @@ -27,16 +27,15 @@ function processIds(ids) { return processedIds; } -async function getTissueExpressions(gene, ideo) { - const cache = ideo.tissueCache; +async function getTissueExpressions(gene, config) { + const cache = Ideogram.tissueCache; const byteRange = cache.byteRangesByName[gene]; // Easier debuggability - if (!ideo.cacheRangeFetch) ideo.cacheRangeFetch = cacheRangeFetch; + if (!Ideogram.cacheRangeFetch) Ideogram.cacheRangeFetch = cacheRangeFetch; if (!byteRange) return null; - const config = ideo.config; let cacheDir = null; if (config.cacheDir) cacheDir = config.cacheDir; const cacheType = 'tissues'; diff --git a/src/js/init/caches/variant-cache-worker.js b/src/js/init/caches/variant-cache-worker.js index dd8157bc..d2aefe68 100644 --- a/src/js/init/caches/variant-cache-worker.js +++ b/src/js/init/caches/variant-cache-worker.js @@ -171,11 +171,11 @@ function parseVariant(line, variantCache) { async function getVariants(gene, ideo) { const variants = []; - const cache = ideo.variantCache; + const cache = Ideogram.variantCache; const byteRange = cache.byteRangesByName[gene]; // Easier debuggability - if (!ideo.cacheRangeFetch) ideo.cacheRangeFetch = cacheRangeFetch; + if (!Ideogram.cacheRangeFetch) Ideogram.cacheRangeFetch = cacheRangeFetch; if (!byteRange) return []; @@ -188,7 +188,7 @@ async function getVariants(gene, ideo) { const orgName = 'homo-sapiens'; const cacheUrl = getCacheUrl(orgName, cacheDir, cacheType, extension); - const geneLocus = ideo.geneCache.lociByName[gene]; + const geneLocus = Ideogram.geneCache.lociByName[gene]; // Get variant data only for the requested gene const data = await cacheRangeFetch(cacheUrl, byteRange); diff --git a/src/js/init/finish-init.js b/src/js/init/finish-init.js index 5fc182f0..7ccb7ecb 100644 --- a/src/js/init/finish-init.js +++ b/src/js/init/finish-init.js @@ -119,7 +119,7 @@ function finishInit(t0) { if (config.geometry === 'collinear') collinearizeChromosomes(ideo); if (ideo.config.debug) console.time('initCache: Ideogram'); - initCaches(ideo).then(() => { + initCaches(ideo.config).then(() => { if (ideo.config.debug) console.timeEnd('initCache: Ideogram'); if (ideo.onLoadCallback) ideo.onLoadCallback(); }); diff --git a/src/js/kit/gene-structure.js b/src/js/kit/gene-structure.js index a7157ad7..48fc368e 100644 --- a/src/js/kit/gene-structure.js +++ b/src/js/kit/gene-structure.js @@ -113,11 +113,11 @@ async function updateGeneStructure(ideo, offset=0) { const isCanonical = (selectedIndex === 0); const menu = document.querySelector('#_ideoGeneStructureMenu'); menu.options[selectedIndex].selected = true; - const svgResults = await getSvg(structure, ideo, ideo.spliceExons); + const svgResults = await getSvg(structure, ideo, Ideogram.spliceExons); const svg = svgResults[0]; const container = document.querySelector('._ideoGeneStructureSvgContainer'); container.innerHTML = svg; - updateHeader(ideo.spliceExons, isCanonical); + updateHeader(Ideogram.spliceExons, isCanonical); writeFooter(container); ideo.addedSubpartListeners = false; addHoverListeners(ideo); @@ -153,7 +153,7 @@ function getSelectedStructure(ideo, offset=0) { } const gene = getGeneFromStructureName(structureName); const geneStructure = - ideo.geneStructureCache[gene].find(gs => gs.name === structureName); + Ideogram.geneStructureCache[gene].find(gs => gs.name === structureName); return [geneStructure, selectedIndex]; @@ -527,7 +527,7 @@ function addHoverListeners(ideo) { ideo.oneTimeDelayTooltipHideMs = 2000; // wait 2.0 s instead of 0.25 s }); - if (ideo.tissueCache) { + if (Ideogram.tissueCache) { const tooltipFooter = document.querySelector('._ideoTooltipFooter'); tooltipFooter.style.display = 'none'; } @@ -535,7 +535,7 @@ function addHoverListeners(ideo) { container.addEventListener('mouseleave', (event) => { ideo.oneTimeDelayTooltipHideMs = 2000; // See "Without this..." note above - if (ideo.tissueCache) { + if (Ideogram.tissueCache) { const tooltipFooter = document.querySelector('._ideoTooltipFooter'); tooltipFooter.style.display = ''; } @@ -625,7 +625,7 @@ function getSpliceToggleHoverTitle(spliceExons) { } function getSpliceToggle(ideo) { - const spliceExons = ideo.spliceExons; + const spliceExons = Ideogram.spliceExons; const modifier = spliceExons ? '' : 'pre-'; const cls = `class="_ideoSpliceToggle ${modifier}mRNA"`; const checked = spliceExons ? 'checked' : ''; @@ -768,8 +768,8 @@ function updateHeader(spliceExons, isCanonical) { } async function toggleSplice(ideo) { - ideo.spliceExons = !ideo.spliceExons; - const spliceExons = ideo.spliceExons; + Ideogram.spliceExons = !Ideogram.spliceExons; + const spliceExons = Ideogram.spliceExons; const [geneStructure, selectedIndex] = getSelectedStructure(ideo); const isCanonical = (selectedIndex === 0); const svgResult = await getSvg(geneStructure, ideo, spliceExons); @@ -895,13 +895,13 @@ function getSubpartBorderLine(subpart) { // function getSvgList(gene, ideo, spliceExons=false) { // if ( -// 'geneStructureCache' in ideo === false || -// gene in ideo.geneStructureCache === false +// 'geneStructureCache' in Ideogram === false || +// gene in Ideogram.geneStructureCache === false // ) { // return [null]; // } -// const svgList = ideo.geneStructureCache[gene].map(geneStructure => { +// const svgList = Ideogram.geneStructureCache[gene].map(geneStructure => { // return getSvg(geneStructure, ideo, spliceExons); // }); @@ -1131,7 +1131,7 @@ function getMenu(gene, ideo, selectedName) { const containerId = '_ideoGeneStructureMenuContainer'; const style = 'margin-bottom: 4px; margin-top: 4px; clear: both;'; - const structures = ideo.geneStructureCache[gene]; + const structures = Ideogram.geneStructureCache[gene]; if (structures.length === 1) { const name = structures[0].name; @@ -1168,14 +1168,14 @@ export async function getGeneStructureHtml(annot, ideo, isParalogNeighborhood) { if ( ideo.config.showGeneStructureInTooltip && !isParalogNeighborhood && !( - 'geneStructureCache' in ideo === false || - gene in ideo.geneStructureCache === false + 'geneStructureCache' in Ideogram === false || + gene in Ideogram.geneStructureCache === false ) ) { ideo.addedSubpartListeners = false; - if ('spliceExons' in ideo === false) ideo.spliceExons = true; - const spliceExons = ideo.spliceExons; - const structure = ideo.geneStructureCache[gene][0]; + 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"'; diff --git a/src/js/kit/pathway-viewer.js b/src/js/kit/pathway-viewer.js index e64a0733..37f72731 100644 --- a/src/js/kit/pathway-viewer.js +++ b/src/js/kit/pathway-viewer.js @@ -1,3 +1,7 @@ +import snarkdown from 'snarkdown'; +import tippy from 'tippy.js'; +import {getTippyConfig} from '../lib'; + const PVJS_URL = 'https://cdn.jsdelivr.net/npm/@wikipathways/pvjs@5.0.1/dist/pvjs.vanilla.js'; const SVGPANZOOM_URL = 'https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.5.0/dist/svg-pan-zoom.min.js'; const CONTAINER_ID = '_ideogramPathwayContainer'; @@ -13,7 +17,7 @@ async function fetchPathwayViewerJson(pwId) { const response = await fetch(url); const pathwayJson = await response.json(); - window.pathwayJson = pathwayJson; + Ideogram.pathwayJson = pathwayJson; return pathwayJson; } @@ -122,7 +126,7 @@ function zoomToEntity(entityId, retryAttempt=0) { } /** Add header bar to pathway diagram with name, link, close button, etc. */ -function addHeader(pwId, pathwayJson, pathwayContainer) { +function addHeader(pwId, pathwayJson, pathwayContainer, showClose=true) { const pathwayName = pathwayJson.pathway.name; const url = `https://wikipathways.org/pathways/${pwId}`; const linkAttrs = `href="${url}" target="_blank" style="margin-left: 4px;"`; @@ -130,29 +134,175 @@ function addHeader(pwId, pathwayJson, pathwayContainer) { // Link to full page on WikiPathways, using pathway title const pathwayLink = `${pathwayName}`; - // Close button - const style = - 'style="float: right; background-color: #aaa; border: none; ' + - 'color: white; font-weight: bold; font-size: 16px; padding: 0px 4px; ' + - 'border-radius: 3px; cursor: pointer;"'; - const buttonAttrs = `class="_ideoPathwayCloseButton" ${style}`; - const closeButton = ``; + let closeButton; + if (showClose) { + // Close button + const style = + 'style="float: right; background-color: #aaa; border: none; ' + + 'color: white; font-weight: bold; font-size: 16px; padding: 0px 4px; ' + + 'border-radius: 3px; cursor: pointer;"'; + const buttonAttrs = `class="_ideoPathwayCloseButton" ${style}`; + closeButton = ``; + } else { + closeButton = ''; + } const headerBar = `
${pathwayLink}${closeButton}
`; pathwayContainer.insertAdjacentHTML('afterBegin', headerBar); - const closeButtonDom = document.querySelector('._ideoPathwayCloseButton'); - closeButtonDom.addEventListener('click', function(event) { - const pathwayContainer = document.querySelector(`#${CONTAINER_ID}`); - pathwayContainer.remove(); - }); + if (showClose) { + const closeButtonDom = document.querySelector('._ideoPathwayCloseButton'); + closeButtonDom.addEventListener('click', function(event) { + const pathwayContainer = document.querySelector(`#${CONTAINER_ID}`); + pathwayContainer.remove(); + }); + } +} + +/** + * + */ +function removeCptacAssayPortalClause(inputText) { + // eslint-disable-next-line max-len + const regex = /Proteins on this pathway have targeted assays available via the \[https:\/\/assays\.cancer\.gov\/available_assays\?wp_id=WP\d+\s+CPTAC Assay Portal\]\./g; + // eslint-disable-next-line max-len + const regex2 = /Proteins on this pathway have targeted assays available via the \[CPTAC Assay Portal\]\(https:\/\/assays\.cancer\.gov\/available_assays\?wp_id=WP\d+\)\./g; + + return inputText.replace(regex, '').replace(regex2, ''); +} + + +function removePhosphoSitePlusClause(inputText) { + // eslint-disable-next-line max-len + const regex = 'Phosphorylation sites were added based on information from PhosphoSitePlus (R), www.phosphosite.org.'; + + return inputText.replace(regex, ''); +} + +/** Convert Markdown links to standard /g, + '' + ); + + return htmlWithClassedLinks; +} + +function formatDescription(rawText) { + rawText = rawText.replaceAll('\r\n\r\n', '\r\n'); + rawText = rawText.replaceAll('\r\n', '

'); + const denoisedPhospho = removePhosphoSitePlusClause(rawText); + const denoisedText = removeCptacAssayPortalClause(denoisedPhospho); + const linkedText = convertMarkdownLinks(denoisedText); + const trimmedText = linkedText.trim(); + return trimmedText; +} + +function getDescription(pathwayJson) { + const rawText = + pathwayJson.pathway.comments.filter( + c => c.source === 'WikiPathways-description' + )[0].content; + const descriptionText = formatDescription(rawText); + + const style = `style="font-weight: bold"`; + + const description = + `
` + + // `
Description
` + + descriptionText + + `
`; + + return description; +} + +function parsePwAnnotations(entitiesById, keys, ontology) { + const pwKeys = keys.filter(k => entitiesById[k].ontology === ontology); + const pwAnnotations = pwKeys.map(k => entitiesById[k]); + return pwAnnotations; +} + +function getPathwayAnnotations(pathwayJson) { + const entitiesById = pathwayJson.entitiesById; + const keys = Object.keys(entitiesById).filter(k => k.startsWith('http://identifiers.org')); + const sentenceCases = { + 'Cell Type': 'Cell type' + }; + const ontologies = [ + 'Cell Type', + 'Disease' + // 'Pathway Ontology' // maybe later + ]; + const pathwayAnnotationsList = ontologies.map(ontology => { + const pwAnnotations = parsePwAnnotations(entitiesById, keys, ontology); + const links = pwAnnotations.map(pwa => { + const id = pwa.xrefIdentifier.replace(':', '_'); + const url = `https://purl.obolibrary.org/obo/${id}`; + return `
${pwa.term}`; + }).join(', '); + + const refinedOntology = sentenceCases[ontology] ?? ontology; + const safeOntology = ontology.replaceAll(' ', '_'); + const cls = `class="ideoPathwayOntology__${safeOntology}"`; + + if (links === '') return ''; + + return `
${refinedOntology}: ${links}
`; + }).join(''); + + if (pathwayAnnotationsList.length === 0) { + return ''; + } + + const style = `style="font-weight: bold"`; + + const pathwayAnnotations = + `
` + + // `
Pathway annotations
` + + pathwayAnnotationsList + + `
`; + + return pathwayAnnotations; +} + +/** Get list of unique genes in pathway */ +export function getPathwayGenes() { + const entities = Object.values(Ideogram.pathwayJson.entitiesById); + const genes = entities.filter(entity => { + return ['GeneProduct', 'RNA', 'Protein'].includes(entity.wpType); + }).map(e => e.textContent); + const uniqueGenes = Array.from(new Set(genes)); + return uniqueGenes; +} + + +function addFooter(pathwayJson, pathwayContainer) { + const description = getDescription(pathwayJson); + const pathwayAnnotations = getPathwayAnnotations(pathwayJson); + const footer = + `
` + + `
` + + description + + `
` + + pathwayAnnotations + + `
`; + pathwayContainer.insertAdjacentHTML('beforeEnd', footer); } /** Fetch and render WikiPathways diagram for given pathway ID */ export async function drawPathway( pwId, sourceGene, destGene, - dimensions={height: 440, width: 900}, retryAttempt=0 + outerSelector='#_ideogramOuterWrap', + dimensions={height: 440, width: 900}, + showClose=true, + geneNodeHoverFn, + pathwayNodeClickFn, + retryAttempt=0 ) { const pvjsScript = document.querySelector(`script[src="${PVJS_URL}"]`); if (!pvjsScript) {loadPvjsScript();} @@ -170,7 +320,12 @@ export async function drawPathway( ) { if (retryAttempt <= 40) { setTimeout(() => { - drawPathway(pwId, sourceGene, destGene, dimensions, retryAttempt++); + drawPathway( + pwId, sourceGene, destGene, + outerSelector, dimensions, showClose, + geneNodeHoverFn, pathwayNodeClickFn, + retryAttempt++ + ); }, 250); return; } else { @@ -192,7 +347,7 @@ export async function drawPathway( const highlights = sourceHighlights.concat(destHighlights); const oldPathwayContainer = document.querySelector(containerSelector); - const ideoContainerDom = document.querySelector('#_ideogramOuterWrap'); + const ideoContainerDom = document.querySelector(outerSelector); if (oldPathwayContainer) { oldPathwayContainer.remove(); } @@ -233,7 +388,9 @@ export async function drawPathway( // const pathwayViewer = new Pvjs(pvjsProps); const pathwayViewer = new Pvjs(pvjsContainer, pvjsProps); - addHeader(pwId, pathwayJson, pathwayContainer); + addHeader(pwId, pathwayJson, pathwayContainer, showClose); + + addFooter(pathwayJson, pathwayContainer); // zoomToEntity(sourceEntityId); @@ -245,4 +402,42 @@ export async function drawPathway( // Notify listeners of event completion const ideogramPathwayEvent = new CustomEvent('ideogramDrawPathway', {detail}); document.dispatchEvent(ideogramPathwayEvent); + + // Add mouseover handler to gene nodes in this pathway diagram + pathwayContainer.querySelectorAll('g.GeneProduct').forEach(geneNode => { + const geneName = geneNode.getAttribute('name'); + let tooltipContent = geneName; + geneNode.addEventListener('mouseover', (event) => { + if (geneNodeHoverFn) { + tooltipContent = geneNodeHoverFn(event, geneName); + geneNode.setAttribute('data-tippy-content', tooltipContent); + } + }); + + geneNode.setAttribute(`data-tippy-content`, tooltipContent); + }); + const tippyConfig = getTippyConfig(); + tippy('g.GeneProduct[data-tippy-content]', tippyConfig); + + // Add click handler to pathway nodes in this pathway diagram + if (pathwayNodeClickFn) { + pathwayContainer.querySelectorAll('g.Pathway').forEach(pathwayNode => { + + // Add customizable click handler + pathwayNode.addEventListener('click', (event) => { + const domClasses = Array.from(pathwayNode.classList); + const pathwayId = domClasses + .find(c => c.startsWith('WikiPathways_')) + .split('WikiPathways_')[1]; // e.g. WikiPathways_WP2815 -> WP2815 + + pathwayNodeClickFn(event, pathwayId); + }); + + // Indicate this new pathway can be rendered on click + const tooltipContent = 'Click to show pathway'; + pathwayNode.setAttribute('data-tippy-content', tooltipContent); + }); + + tippy('g.Pathway[data-tippy-content]', tippyConfig); + } } diff --git a/src/js/kit/protein.js b/src/js/kit/protein.js index b5bd885e..23baca2d 100644 --- a/src/js/kit/protein.js +++ b/src/js/kit/protein.js @@ -190,9 +190,9 @@ function isEligibleforProteinSvg(gene, ideo) { return ( ideo.config.showProteinInTooltip && !( - 'proteinCache' in ideo === false || - gene in ideo.proteinCache === false || - ('spliceExons' in ideo === false || ideo.spliceExons === false) + 'proteinCache' in Ideogram === false || + gene in Ideogram.proteinCache === false || + ('spliceExons' in Ideogram === false || Ideogram.spliceExons === false) ) ); } @@ -220,7 +220,7 @@ function getProteinRect(cds, hasTopology) { * Example: LDLR */ export function getHasTopology(gene, ideo) { - const hasTopology = ideo.proteinCache[gene]?.some(entry => { + const hasTopology = Ideogram.proteinCache[gene]?.some(entry => { return entry.protein.some( feature => isTopologyFeature(feature) ); @@ -238,7 +238,7 @@ export function getProtein( const isEligible = isEligibleforProteinSvg(gene, ideo); if (!isEligible) return ['
', null]; - const entry = ideo.proteinCache[gene].find(d => { + const entry = Ideogram.proteinCache[gene].find(d => { return d.transcriptName === structureName; }); if (!entry) return ['
', null]; diff --git a/src/js/kit/related-genes.js b/src/js/kit/related-genes.js index 42432e61..a2340898 100644 --- a/src/js/kit/related-genes.js +++ b/src/js/kit/related-genes.js @@ -156,8 +156,8 @@ function maybeGeneSymbol(ixn, gene) { /** Reports if interaction node is a gene and not previously seen */ function isInteractionRelevant(rawIxn, gene, nameId, seenNameIds, ideo) { let isGeneSymbol; - if ('geneCache' in ideo && gene.name) { - isGeneSymbol = rawIxn.toLowerCase() in ideo.geneCache.nameCaseMap; + if ('geneCache' in Ideogram && gene.name) { + isGeneSymbol = rawIxn.toLowerCase() in Ideogram.geneCache.nameCaseMap; } else { isGeneSymbol = maybeGeneSymbol(rawIxn, gene); } @@ -186,9 +186,9 @@ async function fetchInteractions(gene, ideo) { let data = {result: []}; - if (ideo.interactionCache) { - if (upperGene in ideo.interactionCache) { - data = ideo.interactionCache[upperGene]; + if (Ideogram.interactionCache) { + if (upperGene in Ideogram.interactionCache) { + data = Ideogram.interactionCache[upperGene]; } } else { @@ -273,7 +273,7 @@ async function fetchInteractions(gene, ideo) { if (numIxns > limitIxns) { // Only show up to 20 interacting genes, // ordered by interest rank of interacting gene. - const ranks = ideo.geneCache.interestingNames.map(g => g.toLowerCase()); + const ranks = Ideogram.geneCache.interestingNames.map(g => g.toLowerCase()); const ixnGenes = Object.keys(ixns); const rankedIxnGenes = ixnGenes .map(gene => { @@ -437,27 +437,27 @@ function throwGeneNotFound(geneSymbol, ideo) { * E.g. getGeneBySynonym("p53", ideo) returns "TP53" */ function getGeneBySynonym(name, ideo) { - if (!ideo.synonymCache) return null; + if (!Ideogram.synonymCache) return null; const nameLc = name.toLowerCase(); - if (!ideo.synonymCache?.nameCaseMap) { + if (!Ideogram.synonymCache?.nameCaseMap) { // JIT initialization of canonicalized synonym lookup data. // Done only once. const nameCaseMap = {}; - for (const gene in ideo.synonymCache.byGene) { - const synonyms = ideo.synonymCache.byGene[gene]; + for (const gene in Ideogram.synonymCache.byGene) { + const synonyms = Ideogram.synonymCache.byGene[gene]; nameCaseMap[gene.toLowerCase()] = synonyms.map(s => s.toLowerCase()); } - ideo.synonymCache.nameCaseMap = nameCaseMap; + Ideogram.synonymCache.nameCaseMap = nameCaseMap; } - const nameCaseMap = ideo.synonymCache.nameCaseMap; + const nameCaseMap = Ideogram.synonymCache.nameCaseMap; for (const geneLc in nameCaseMap) { const synonymsLc = nameCaseMap[geneLc]; if (synonymsLc.includes(nameLc)) { // Got a hit! Return standard gene symbol, e.g. "tp53" -> "TP53". - return ideo.geneCache.nameCaseMap[geneLc]; + return Ideogram.geneCache.nameCaseMap[geneLc]; } } @@ -469,7 +469,7 @@ function getGeneBySynonym(name, ideo) { * Construct objects that match format of MyGene.info API response */ function fetchGenesFromCache(names, type, ideo) { - const cache = ideo.geneCache; + const cache = Ideogram.geneCache; const isSymbol = (type === 'symbol'); const locusMap = isSymbol ? cache.lociByName : cache.lociById; const nameMap = isSymbol ? cache.idsByName : cache.namesById; @@ -604,7 +604,7 @@ async function fetchGenes(names, type, ideo) { const queryStringBase = `?q=${qParam}&species=${taxid}&fields=`; - if (ideo.geneCache) { + if (Ideogram.geneCache) { const hits = fetchGenesFromCache(names, type, ideo); // Asynchronously fetch full name, but don't await the response, because @@ -692,6 +692,7 @@ async function fetchParalogPositionsFromMyGeneInfo( const annots = []; const cached = homologs.length && typeof homologs[0] === 'string'; + console.log('cached', cached) const ensemblIds = cached ? homologs : homologs.map(homolog => homolog.id); const data = await fetchGenes(ensemblIds, 'ensemblgene', ideo); @@ -705,7 +706,7 @@ async function fetchParalogPositionsFromMyGeneInfo( annots.push(annot); const description = - ideo.tissueCache ? '' : `Paralog of ${searchedGene.name}`; + Ideogram.tissueCache ? '' : `Paralog of ${searchedGene.name}`; const {name, ensemblId} = parseNameAndEnsemblIdFromMgiGene(gene); const type = 'paralogous gene'; @@ -810,11 +811,11 @@ function plotParalogNeighborhoods(annots, ideo) { annotStop = overlayAnnotLength; }; - if ('geneCache' in ideo) { + if ('geneCache' in Ideogram) { paralogs = paralogs.map(paralog => { - paralog.fullName = ideo.geneCache.fullNamesById[paralog.id]; + paralog.fullName = Ideogram.geneCache.fullNamesById[paralog.id]; - const ranks = ideo.geneCache.interestingNames; + const ranks = Ideogram.geneCache.interestingNames; if (ranks.includes(paralog.name)) { paralog.rank = ranks.indexOf(paralog.name) + 1; } else { @@ -861,14 +862,14 @@ async function fetchParalogs(annot, ideo) { let homologs; // Fetch paralogs - if (ideo.paralogCache) { + if (Ideogram.paralogCache) { // const baseUrl = 'http://localhost:8080/dist/data/cache/paralogs/'; // const url = `${baseUrl}homo-sapiens/${annot.name}.tsv`; // const response = await fetch(url); // const oneRowTsv = await response.text(); // const rawHomologEnsemblIds = oneRowTsv.split('\t'); // homologs = rawHomologEnsemblIds.map(r => getEnsemblId('ENSG', r)); - const paralogsByName = ideo.paralogCache.paralogsByName; + const paralogsByName = Ideogram.paralogCache.paralogsByName; const nameUc = annot.name.toUpperCase(); const hasParalogs = nameUc in paralogsByName; homologs = hasParalogs ? paralogsByName[nameUc] : []; @@ -1229,7 +1230,7 @@ function mergeDescriptions(annot, desc, ideo) { } }); // Object.assign({}, descriptions[annot.name]); - if ('type' in otherDesc && !ideo.tissueCache) { + if ('type' in otherDesc && !Ideogram.tissueCache) { mergedDesc.type += ', ' + otherDesc.type; mergedDesc.description += `

${otherDesc.description}`; } @@ -1261,39 +1262,43 @@ function mergeAnnots(unmergedAnnots) { return mergedAnnots; } +function hasTissueCache() { + return Ideogram.tissueCache && Object.keys(Ideogram.tissueCache).length > 0; +} + /** * Prevents bug when showing gene leads instantly on page load, * then hovering over an annotation, as in e.g. * https://eweitz.github.io/ideogram/gene-leads */ -function waitForTissueCache(geneNames, ideo, n) { +function waitForTissueCache(geneNames, config, n) { setTimeout(() => { if (n < 40) { // 40 * 50 ms = 2 s - if (!ideo.tissueCache) { - waitForTissueCache(geneNames, ideo, n + 1); + if (!hasTissueCache()) { + waitForTissueCache(geneNames, config, n + 1); } else { - setTissueExpressions(geneNames, ideo); + setTissueExpressions(geneNames, config); } } }, 50); } -async function setTissueExpressions(geneNames, ideo) { +async function setTissueExpressions(geneNames, config) { if ( - !ideo.tissueCache - // || !(annot.name in ideo.tissueCache.byteRangesByName) + !hasTissueCache() + // || !(annot.name in Ideogram.tissueCache.byteRangesByName) ) { - waitForTissueCache(geneNames, ideo, 0); + waitForTissueCache(geneNames, config, 0); return; } const tissueExpressionsByGene = {}; - const cache = ideo.tissueCache; + const cache = Ideogram.tissueCache; const promises = []; geneNames.forEach(async gene => { const promise = new Promise(async (resolve) => { - const tissueExpressions = await cache.getTissueExpressions(gene, ideo); + const tissueExpressions = await cache.getTissueExpressions(gene, config); tissueExpressionsByGene[gene] = tissueExpressions; resolve(); }); @@ -1302,7 +1307,7 @@ async function setTissueExpressions(geneNames, ideo) { await Promise.all(promises); - ideo.tissueExpressionsByGene = tissueExpressionsByGene; + Ideogram.tissueExpressionsByGene = tissueExpressionsByGene; } function onBeforeDrawAnnots() { @@ -1330,7 +1335,7 @@ function onBeforeDrawAnnots() { } } - setTissueExpressions(geneNames, ideo); + setTissueExpressions(geneNames, ideo.config); } function filterAndDrawAnnots(annots, ideo) { @@ -1652,7 +1657,20 @@ function addPathwayListeners(ideo) { // const pathwayName = target.getAttribute('data-pathway-name'); // const pathway = {id: pathwayId, name: pathwayName}; // plotPathwayGenes(searchedGene, pathway, ideo); - drawPathway(pathwayId, searchedGene, interactingGene); + function geneNodeHoverFn(event, geneName) { + console.log('in geneNodeHoverFn') + return '
ok ' + geneName + '
1234
'; + } + + function pathwayNodeClickFn(event, pathwayId) { + const pathwayNode = event.target; + console.log('in pathwayNodeClickFn, pathwayNode', pathwayNode); + console.log('in pathwayNodeClickFn, pathwayId', pathwayId); + } + + drawPathway(pathwayId, searchedGene, interactingGene, + undefined, undefined, undefined, + geneNodeHoverFn, pathwayNodeClickFn); event.stopPropagation(); }); }); @@ -1672,7 +1690,7 @@ function centralizeTooltipPosition() { function onDidShowAnnotTooltip() { const ideo = this; - if (ideo.tissueCache) { + if (Ideogram.tissueCache) { centralizeTooltipPosition(); } handleTooltipClick(ideo); @@ -1843,7 +1861,7 @@ async function decorateAnnot(annot) { const queriedSynonym = descObj.synonym; const synStyle = 'style="font-style: italic"'; synonym = `
Synonym: ${queriedSynonym}
`; - // const synList = ideo.synonymCache.byGene[annot.name]; + // const synList = Ideogram.synonymCache.byGene[annot.name]; // const litSyns = synList.map(s => { // // Emphasize ("highlight") any synonyms that match the user's query // if (s.toLowerCase() === queriedSynonym.toLowerCase()) { @@ -1956,6 +1974,7 @@ const globalKitDefaults = { showAnnotLabels: true, showGeneStructureInTooltip: true, showProteinInTooltip: true, + showVariantInTooltip: false, chrFillColor: {centromere: '#DAAAAA'} }; diff --git a/src/js/kit/tissue.js b/src/js/kit/tissue.js index ad908c86..7227e2d6 100644 --- a/src/js/kit/tissue.js +++ b/src/js/kit/tissue.js @@ -464,7 +464,7 @@ function getMetricTicks(teObject, height) { function addDetailedCurve(traceDom, ideo) { const gene = traceDom.getAttribute('data-gene'); const tissue = traceDom.getAttribute('data-tissue'); - const tissueExpressions = ideo.tissueExpressionsByGene[gene]; + const tissueExpressions = Ideogram.tissueExpressionsByGene[gene]; let teObject = tissueExpressions.find(t => t.tissue === tissue); const maxWidthPx = 225; // Same width as RNA & protein diagrams @@ -580,7 +580,7 @@ function getExpressionPlotHtml(gene, tissueExpressions, ideo) { }).join(''); let containerStyle = 'style="margin-bottom: 30px;"'; - const hasStructure = gene in ideo.geneStructureCache; + const hasStructure = gene in Ideogram.geneStructureCache; if (!hasStructure) { // e.g. MALAT1 containerStyle = 'style="margin-bottom: 10px;"'; } @@ -605,7 +605,7 @@ function updateTissueExpressionPlot(ideo) { const plotParent = plot.parentElement; const gene = document.querySelector('#ideo-related-gene').innerText; - const tissueExpressions = ideo.tissueExpressionsByGene[gene]; + const tissueExpressions = Ideogram.tissueExpressionsByGene[gene]; const newPlotHtml = getExpressionPlotHtml(gene, tissueExpressions, ideo); @@ -665,7 +665,7 @@ function focusMiniCurve(traceDom, ideo, reset=false) { const refTissue = reset ? null : traceDom.getAttribute('data-tissue'); const numTissues = !ideo.showTissuesMore ? 10 : 3; - let tissueExpressions = ideo.tissueExpressionsByGene[gene]; + let tissueExpressions = Ideogram.tissueExpressionsByGene[gene]; const maxPx = MINI_CURVE_WIDTH; const relative = true; @@ -709,7 +709,10 @@ function focusMiniCurve(traceDom, ideo, reset=false) { } export function getTissueHtml(annot, ideo) { - if (!ideo.tissueCache || !(annot.name in ideo.tissueCache.byteRangesByName)) { + if ( + !Ideogram.tissueCache || + !(annot.name in Ideogram.tissueCache.byteRangesByName) + ) { // e.g. MIR23A return '
'; } @@ -719,7 +722,7 @@ export function getTissueHtml(annot, ideo) { } const gene = annot.name; - const tissueExpressions = ideo.tissueExpressionsByGene[gene]; + const tissueExpressions = Ideogram.tissueExpressionsByGene[gene]; if (!tissueExpressions) return; const tissueHtml = getExpressionPlotHtml(gene, tissueExpressions, ideo); diff --git a/src/js/kit/variant.js b/src/js/kit/variant.js index 18654dcd..f32f279c 100644 --- a/src/js/kit/variant.js +++ b/src/js/kit/variant.js @@ -326,7 +326,10 @@ export async function getVariantsSvg( const gene = getGeneFromStructureName(structureName, ideo); - const cache = ideo.variantCache; + const cache = Ideogram.variantCache; + if (!cache) { + return null; + } let rawVariants = await cache.getVariants(gene, ideo); diff --git a/src/js/kit/wikipathways.js b/src/js/kit/wikipathways.js index 67c28d21..a14e440b 100644 --- a/src/js/kit/wikipathways.js +++ b/src/js/kit/wikipathways.js @@ -425,7 +425,7 @@ export async function fetchPathwayInteractions(searchedGene, pathwayId, ideo) { nodes.forEach(node => { const label = node.getAttribute('TextLabel'); const normLabel = label.toLowerCase(); - const isKnownGene = normLabel in ideo.geneCache.nameCaseMap; + const isKnownGene = normLabel in Ideogram.geneCache.nameCaseMap; if (isKnownGene) { genes[label] = 1; }