UNPKG

ideogram

Version:

Chromosome visualization for the web

1,577 lines (1,297 loc) 47.2 kB
/** * @fileoverview Kit used in "Related genes" example * * This file simplifies client code for reusing a "related genes" ideogram -- * which finds and displays related genes for a searched gene. * * Related genes here are either "interacting genes" or "paralogs". * Interacting genes are genes immediately upstream or downstream of the * searched gene in a biochemical pathway. Paralogs are evolutionarily * similar genes in the same species. * * Data sources: * - Interacting genes: WikiPathways * - Paralogs: Ensembl * - Genomic coordinates: Ensembl, via MyGene.info * * Features provided by this module help users discover and explore genes * related to their gene of interest. * * The reference implementation is available at: * https://eweitz.github.io/ideogram/related-genes */ import {decompressSync, strFromU8} from 'fflate'; import { initAnalyzeRelatedGenes, analyzePlotTimes, analyzeRelatedGenes, timeDiff, getRelatedGenesByType, getRelatedGenesTooltipAnalytics } from './analyze-related-genes'; import { sortAnnotsByRank, applyRankCutoff, setAnnotRanks } from '../annotations/annotations'; import {writeLegend} from '../annotations/legend'; import {getAnnotDomId} from '../annotations/process'; import {getDir, deepCopy} from '../lib'; import initGeneCache, {getEnsemblId} from '../gene-cache'; import initParalogCache, {hasParalogCache} from '../paralog-cache'; import initInteractionCache from '../interaction-cache'; import { fetchGpmls, summarizeInteractions, fetchPathwayInteractions } from './wikipathways'; // import {drawAnnotsByLayoutType} from '../annotations/draw'; // import {organismMetadata} from '../init/organism-metadata'; /** Sets DOM IDs for ideo.relatedAnnots; needed to associate labels */ function setRelatedAnnotDomIds(ideo) { const updated = []; const sortedChrNames = ideo.chromosomesArray.map((chr) => { return chr.name; }); // Count two related annots for same gene as one. // E.g. gene Foo can both interact with and be paralog of gene Bar // Instead of count Foo interacting annot and Foo paralog annot as two, // only count it as one as they are merged in downstream UI. // // Searching STAT3 without this block shows the problem this fixes. const seenNames = {}; ideo.relatedAnnots = ideo.relatedAnnots.filter(annot => { if (annot.name in seenNames) { return false; } seenNames[annot.name] = 1; return true; }); // Arrange related annots by chromosome const annotsByChr = {}; ideo.relatedAnnots.forEach((annot) => { if (annot.chr in annotsByChr) { annotsByChr[annot.chr].push(annot); } else { annotsByChr[annot.chr] = [annot]; } }); // Sort related annots by relevance within each chromosome const relevanceSortedAnnotsNamesByChr = {}; Object.entries(annotsByChr).map(([chr, annots]) => { annots = setAnnotRanks(annots, ideo); // Sort so first annots are drawn last, and thus at top layer annots.sort((a, b) => -ideo.annotSortFunction(a, b)); const annotNames = annots.map((annot) => annot.name); relevanceSortedAnnotsNamesByChr[chr] = annotNames; }); // annotsByChr.annots.sort((a, b) => { // // Reverse-sort, so first annots are drawn last, and thus at top layer // return -ideo.annotSortFunction(a, b); // }); ideo.relatedAnnots.forEach((annot) => { const chr = annot.chr; // Annots have DOM IDs keyed by chromosome index and annotation index. // We reconstruct those here using structures built in two blocks above. const chrIndex = sortedChrNames.indexOf(chr); const annotIndex = relevanceSortedAnnotsNamesByChr[chr].indexOf(annot.name); annot.domId = getAnnotDomId(chrIndex, annotIndex); updated.push(annot); }); ideo.relatedAnnots = updated; } /** * Determines if interaction node might be a gene * * Some interaction nodes are biological processes; this filters out many. * Filtering these out makes downstream queries faster. * * ixn {Object} Interaction from WikiPathways * gene {Object} Gene from MyGene.info */ function maybeGeneSymbol(ixn, gene) { return ( ixn !== '' && !ixn.includes(' ') && !ixn.includes('/') && // e.g. Akt/PKB ixn.toLowerCase() !== gene.name.toLowerCase() ); } // /** Helpful for debugging race conditions caused by concurrency */ // const sleep = (delay) => { // new Promise((resolve) => setTimeout(resolve, delay)); // } /** 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; } else { isGeneSymbol = maybeGeneSymbol(rawIxn, gene); } return isGeneSymbol && !(nameId in seenNameIds); } /** * Retrieves interacting genes from WikiPathways API * * Docs: * https://webservice.wikipathways.org/ui/ * https://www.wikipathways.org/index.php/Help:WikiPathways_Webservice/API * * Examples: * https://webservice.wikipathways.org/findInteractions?query=ACE2&format=json * https://webservice.wikipathways.org/findInteractions?query=RAD51&format=json */ async function fetchInteractions(gene, ideo) { const ixns = {}; const seenNameIds = {}; const orgNameSimple = ideo.config.organism.replace(/-/g, ' '); const upperGene = gene.name.toUpperCase(); let data = {result: []}; if (ideo.interactionCache) { if (upperGene in ideo.interactionCache) { data = ideo.interactionCache[upperGene]; } } else { // const queryString = `?query=${gene.name}&format=json`; // const url = // `https://webservice.wikipathways.org/findInteractions${queryString}`; // const url = `http://localhost:8080/dist/data/cache/${gene.name}.json.gz`; const url = `https://cdn.jsdelivr.net/npm/ixn2/${upperGene}.json.gz`; // await sleep(3000); const response = await fetch(url); // const data = await response.json(); if (response.ok) { const blob = await response.blob(); const uint8Array = new Uint8Array(await blob.arrayBuffer()); data = JSON.parse(strFromU8(decompressSync(uint8Array))); } } // For each interaction, get nodes immediately upstream and downstream. // Filter out pathway nodes that are definitely not gene symbols, then // group pathways by gene symbol. Each interacting gene can have // multiple pathways. data.result.forEach(interaction => { if (interaction.species.toLowerCase() === orgNameSimple) { const right = interaction.fields.right.values; const left = interaction.fields.left.values; // let mediator = []; // if ('mediator' in interaction.fields) { // mediator = interaction.fields.mediator.values; // console.log('mediator', mediator) // } // const rawIxns = right.concat(left, mediator); const rawIxns = right.concat(left); const name = interaction.name; const id = interaction.id; // rawIxns can contain multiple genes, e.g. when // a group (i.e. a complex or a set of paralogs) // interacts with the searched gene const wrappedRawIxns = rawIxns.map(rawIxn => { return {name: rawIxn, color: ''}; }); const sortedRawIxns = sortAnnotsByRank(wrappedRawIxns, ideo).map(i => i.name); sortedRawIxns.forEach(rawIxn => { const normRawIxn = rawIxn.toLowerCase(); // Prevent overwriting searched gene. Occurs with e.g. human CD4 if (normRawIxn.includes(gene.name.toLowerCase())) return; // if (rawIxn === '') return; // Avoid oddly blank placeholders const nameId = name + id; const isRelevant = isInteractionRelevant(normRawIxn, gene, nameId, seenNameIds, ideo); if (isRelevant) { seenNameIds[nameId] = 1; const ixn = {name, pathwayId: id}; if (normRawIxn in ixns) { ixns[normRawIxn].push(ixn); } else { ixns[normRawIxn] = [ixn]; } } }); } }); return ixns; } /** * Queries MyGene.info API, returns parsed JSON * * Docs: * https://docs.mygene.info/en/v3/ * * Example: * https://mygene.info/v3/query?q=symbol:cdk2%20OR%20symbol:brca1&species=9606&fields=symbol,genomic_pos,name */ async function fetchMyGeneInfo(queryString) { const myGeneBase = 'https://mygene.info/v3/query'; const response = await fetch(myGeneBase + queryString + '&size=400'); const data = await response.json(); return data; } function parseNameAndEnsemblIdFromMgiGene(gene) { const name = gene.name; const id = gene.genomic_pos.ensemblgene; let ensemblId = id; if (typeof id === 'undefined') { // Encountered in AKT3, when querying related genes for MTOR // A 'chr'omosome value containing _ indicates an alt loci scaffold, // so ignore that and take the Ensembl ID associated with the // first position of a primary chromosome. ensemblId = gene.genomic_pos.filter(pos => !pos.chr.includes('_'))[0].ensemblgene; } return {name, ensemblId}; } /** * Summarizes genes in a pathway * * This comprises most of the content for tooltips for pathway genes. */ function describePathwayGene(pathwayGene, searchedGene, pathway, summary) { let ixnsDescription = ''; const pathwaysBase = 'https://www.wikipathways.org/index.php/Pathway:'; const url = `${pathwaysBase}${pathway.id}`; const attrs = `href="${url}" ` + `target="_blank" ` + `title="See pathway diagram in WikiPathways"`; ixnsDescription = `${summary} ${searchedGene.name} in:</br/>` + `<a ${attrs}>${pathway.name}</a>`; const {name, ensemblId} = parseNameAndEnsemblIdFromMgiGene(pathwayGene); const type = 'pathway gene'; const descriptionObj = { description: ixnsDescription, ixnsDescription, ensemblId, name, type }; return descriptionObj; } /** * Summarizes interactions for a gene * * This comprises most of the content for tooltips for interacting genes. */ function describeInteractions(gene, ixns, searchedGene) { const pathwayIds = []; const pathwayNames = []; let ixnsDescription = ''; if (typeof ixns !== 'undefined') { // ixns is undefined when querying e.g. CDKN1B in human const links = ixns.map(ixn => { // pathwayIds.push(ixn.pathwayId); // pathwayNames.push(ixn.name); // const attrs = // `class="ideo-pathway-link" ` + // `title="Click to search for other genes in this pathway" ` + // `style="cursor: pointer" ` + // `data-pathway-id="${ixn.pathwayId}" ` + // `data-pathway-name="${ixn.name}"`; // return `<a ${attrs}>${ixn.name}</a>`; const pathwaysBase = 'https://www.wikipathways.org/index.php/Pathway:'; const url = `${pathwaysBase}${ixn.pathwayId}`; pathwayIds.push(ixn.pathwayId); pathwayNames.push(ixn.name); const attrs = `class="ideo-pathway-link" ` + `title="View in WikiPathways" ` + `data-pathway-id="${ixn.pathwayId}" ` + `target="_blank" ` + `href="${url}"`; return `<a ${attrs}>${ixn.name}</a>`; }).join('<br/>'); ixnsDescription = `Interacts with ${searchedGene.name} in:<br/>${links}`; } const {name, ensemblId} = parseNameAndEnsemblIdFromMgiGene(gene); const type = 'interacting gene'; const descriptionObj = { description: ixnsDescription, ixnsDescription, ensemblId, name, type, pathwayIds, pathwayNames }; return descriptionObj; } /** Throw error when searched gene (e.g. "Foo") isn't found */ function throwGeneNotFound(geneSymbol, ideo) { const organism = ideo.organismScientificName; throw Error(`"${geneSymbol}" is not a known gene in ${organism}`); } /** * Fetch genes from cache * Construct objects that match format of MyGene.info API response */ function fetchGenesFromCache(names, type, ideo) { const cache = ideo.geneCache; const isSymbol = (type === 'symbol'); const locusMap = isSymbol ? cache.lociByName : cache.lociById; const nameMap = isSymbol ? cache.idsByName : cache.namesById; const hits = names.map(name => { const nameLc = name.toLowerCase(); if (!locusMap[name] && !cache.nameCaseMap[nameLc]) { if (isSymbol) { throwGeneNotFound(name, ideo); } else { return; } } // Canonicalize name if it is mistaken in upstream data source. // This can sometimes happen in WikiPathways, e.g. when searching // interactions for rat Pten, it includes a result for "PIK3CA". // In that case, this would correct PIK3CA to be Pik3ca. if (isSymbol && !locusMap[name] && cache.nameCaseMap[nameLc]) { name = cache.nameCaseMap[nameLc]; } const locus = locusMap[name]; const symbol = isSymbol ? name : nameMap[name]; const ensemblId = isSymbol ? nameMap[name] : name; const fullName = cache.fullNamesById[ensemblId]; const hit = { symbol, name: fullName, source: 'cache', genomic_pos: { chr: locus[0], start: locus[1], end: locus[2], ensemblgene: ensemblId } }; return hit; }); const hitsWithGenomicPos = hits.filter(hit => hit !== undefined); return hitsWithGenomicPos; } /** Fetch genes from cache, or, if needed, from MyGene.info API */ async function fetchGenes(names, type, ideo) { let data; // Account for single-gene fetch if (typeof names === 'string') names = [names]; // Query parameter for MyGene.info API const qParam = names.map(name => `${type}:${name.trim()}`).join(' OR '); const taxid = ideo.config.taxid; const queryStringBase = `?q=${qParam}&species=${taxid}&fields=`; if (ideo.geneCache) { const hits = fetchGenesFromCache(names, type, ideo); // Asynchronously fetch full name, but don't await the response, because // full names are only shown upon hovering over an annotation. // const queryString = `${queryStringBase}symbol,name`; // data = fetchMyGeneInfo(queryString).then(data => { // data.hits.forEach((hit) => { hits.forEach((hit) => { const symbol = hit.symbol; const fullName = hit.name; if (symbol in ideo.annotDescriptions.annots) { ideo.annotDescriptions.annots[symbol].name = fullName; } else { ideo.annotDescriptions.annots[symbol] = {name: fullName}; } }); // }); data = {hits, fromGeneCache: true}; } else { // Fetch gene data from MyGene.info const queryString = `${queryStringBase}symbol,genomic_pos,name`; data = await fetchMyGeneInfo(queryString); } return data; } /** * Retrieves position and other data on interacting genes from MyGene.info */ async function fetchInteractionAnnots(interactions, searchedGene, ideo) { const annots = []; const symbols = Object.keys(interactions); if (symbols.length === 0) return annots; const data = await fetchGenes(symbols, 'symbol', ideo); data.hits.forEach(gene => { // If hit lacks position // or is same as searched gene (e.g. search for human SRC), // then skip processing if ( 'genomic_pos' in gene === false || gene.symbol === searchedGene.name ) { return; } const annot = parseAnnotFromMgiGene(gene, ideo, 'purple'); annots.push(annot); const ixns = interactions[gene.symbol.toLowerCase()]; const descriptionObj = describeInteractions(gene, ixns, searchedGene); mergeDescriptions(annot, descriptionObj, ideo); }); // Fetch GPML files to use when updating interaction descriptions with // refined direction. fetchGpmls(ideo); return annots; } /** Fetch paralog positions from MyGeneInfo */ async function fetchParalogPositionsFromMyGeneInfo( homologs, searchedGene, ideo ) { const annots = []; const cached = homologs.length && typeof homologs[0] === 'string'; const ensemblIds = cached ? homologs : homologs.map(homolog => homolog.id); const data = await fetchGenes(ensemblIds, 'ensemblgene', ideo); data.hits.forEach(gene => { // If hit lacks position, skip processing if ('genomic_pos' in gene === false) return; if ('name' in gene === false) return; const annot = parseAnnotFromMgiGene(gene, ideo, 'pink'); annots.push(annot); const description = `Paralog of ${searchedGene.name}`; const {name, ensemblId} = parseNameAndEnsemblIdFromMgiGene(gene); const type = 'paralogous gene'; const descriptionObj = {description, ensemblId, name, type}; mergeDescriptions(annot, descriptionObj, ideo); }); return annots; } function overplotParalogs(annots, ideo) { if (!ideo.config.showParalogNeighborhoods) return; if (annots.length < 2) return; // Arrays of paralogs within 10 Mbp of each other const neighborhoods = {}; neighborhoods[annots[0].chr] = {}; neighborhoods[annots[0].chr][annots[0].start] = [annots[0]]; const windowInt = 2_000_000; const windowProse = '2 Mbp'; for (let i = 1; i < annots.length; i++) { const annot = annots[i]; const chr = annot.chr; const start = annot.start; if (chr in neighborhoods) { const starts = Object.keys(neighborhoods[chr]); for (let j = 0; j < starts.length; j++) { const startJInt = parseInt(starts[j]); if (Math.abs(start - startJInt) < windowInt) { neighborhoods[chr][startJInt].push(annot); } else { neighborhoods[chr][start] = [annot]; } } } else { neighborhoods[chr] = {}; neighborhoods[chr][start] = [annot]; } } // Big enough to see and hover const overlayAnnotLength = 15_000_000; const searchedGene = getSearchedFromDescriptions(ideo); const neighborhoodAnnots = Object.entries(neighborhoods).map(([chr, neighborhood], index) => { const start = parseInt(Object.keys(neighborhood)[0]); let paralogs = Object.values(neighborhood)[0]; if (paralogs.length < 2) { return {paralogs}; } // paralogs.map(paralog => { // console.log(paralog); // }) const description = `${paralogs.length} nearby paralogs of ${searchedGene}`; const chrLength = ideo.chromosomes[ideo.config.taxid][chr].bpLength; let annotStart = start - overlayAnnotLength/2; let annotStop = start + overlayAnnotLength/2; if (annotStop > chrLength) { annotStart = start - overlayAnnotLength; annotStop = chrLength; } else if (annotStart < 1) { annotStart = 1; annotStop = overlayAnnotLength; }; if ('geneCache' in ideo) { paralogs = paralogs.map(paralog => { paralog.fullName = ideo.geneCache.fullNamesById[paralog.id]; return paralog; }); } const key = 'paralogNeighborhood-' + index; const fStart = start.toLocaleString(); // Format for readability const displayCoordinates = `chr${chr}:${fStart} ± ${windowProse}`; const annot = { name: key, chr, start: annotStart, stop: annotStop, color: 'pink', description, paralogs, type: 'paralog neighborhood', displayCoordinates }; ideo.annotDescriptions.annots[annot.name] = annot; return annot; }).filter(n => n.paralogs.length > 1); if (neighborhoodAnnots.length > 0) { // console.log('neighborhoodAnnots') // console.log(neighborhoodAnnots.map(na => na)); ideo.drawAnnots(neighborhoodAnnots, 'overlay', true, true); moveLegend(); } } /** * Fetch paralogs of searched gene */ async function fetchParalogs(annot, ideo) { const taxid = ideo.config.taxid; let homologs; // Fetch paralogs if (ideo.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 nameUc = annot.name.toUpperCase(); const hasParalogs = nameUc in paralogsByName; homologs = hasParalogs ? paralogsByName[nameUc] : []; } else { const params = `&format=condensed&type=paralogues&target_taxon=${taxid}`; const path = `/homology/id/${annot.id}?${params}`; const ensemblHomologs = await Ideogram.fetchEnsembl(path); homologs = ensemblHomologs.data[0].homologies; } // Fetch positions of paralogs let annots = await fetchParalogPositionsFromMyGeneInfo(homologs, annot, ideo); // Omit genes named like "AC113554.1", which is an "accession.version". // Such accVers are raw and poorly suited here. annots = annots.filter(annot => { const isAccVer = annot.name.match(/^AC[0-9.]+$/); return !isAccVer; }); return annots; } /** * Filters out placements on alternative loci scaffolds, an advanced * genome assembly feature we are not concerned with in ideograms. * * Example: * https://mygene.info/v3/query?q=symbol:PTPRC&species=9606&fields=symbol,genomic_pos,name */ function getGenomicPos(gene, ideo) { let genomicPos = null; if (Array.isArray(gene.genomic_pos)) { genomicPos = gene.genomic_pos.filter(pos => { return pos.chr in ideo.chromosomes[ideo.config.taxid]; })[0]; } else { genomicPos = gene.genomic_pos; } return genomicPos; } /** * Transforms MyGene.info (MGI) gene into Ideogram annotation */ function parseAnnotFromMgiGene(gene, ideo, color='red') { const genomicPos = getGenomicPos(gene, ideo); const annot = { name: gene.symbol, chr: genomicPos.chr, start: genomicPos.start, stop: genomicPos.end, id: genomicPos.ensemblgene, color }; return annot; } function moveLegend() { const ideoInnerDom = document.querySelector('#_ideogramInnerWrap'); const decorPad = setRelatedDecorPad({}).legendPad; const left = decorPad + 20; const legendStyle = `position: absolute; top: 15px; left: ${left}px`; const legend = document.querySelector('#_ideogramLegend'); ideoInnerDom.prepend(legend); legend.style = legendStyle; } /** Filter annotations to only include those in configured list */ function applyAnnotsIncludeList(annots, ideo) { if (ideo.config.annotsInList === 'all') return annots; const includedAnnots = []; annots.forEach(annot => { if (ideo.config.annotsInList.includes(annot.name.toLowerCase())) { includedAnnots.push(annot); } }); return includedAnnots; } /** Fetch and draw interacting genes, return Promise for annots */ function processInteractions(annot, ideo) { return new Promise(async (resolve) => { const t0 = performance.now(); const interactions = await fetchInteractions(annot, ideo); const annots = await fetchInteractionAnnots(interactions, annot, ideo); ideo.relatedAnnots.push(...annots); finishPlotRelatedGenes('interacting', ideo); ideo.time.rg.interactions = timeDiff(t0); resolve(); }); } /** Find and draw paralogs, return Promise for annots */ function processParalogs(annot, ideo) { return new Promise(async (resolve) => { const t0 = performance.now(); const annots = await fetchParalogs(annot, ideo); ideo.relatedAnnots.push(...annots); finishPlotRelatedGenes('paralogous', ideo); overplotParalogs(annots, ideo); ideo.time.rg.paralogs = timeDiff(t0); resolve(); }); } // /** // * Sorts gene names consistently. // * // * Might also loosely rank by first-discovered or most prominent // */ // function sortGeneNames(aName, bName) { // // Rank shorter names above longer names // if (bName.length !== aName.length) return bName.length - aName.length; // // Rank names of equal length alphabetically // return [aName, bName].sort().indexOf(aName) === 0 ? 1 : -1; // } /** Sorts by relevance of related type, then rank */ export function sortByRelatedType(a, b) { var aName, bName, aColor, bColor; if ('name' in a) { // Locally processed annotations aName = a.name; bName = b.name; aColor = a.color; bColor = b.color; } else { // Raw annotations [aName, aColor] = [a[0], a[3]]; [bName, bColor] = [b[0], b[3]]; } // Rank red (searched gene) highest if (aColor === 'red') return -1; if (bColor === 'red') return 1; // Rank purple (interacting gene) above pink (paralogous gene) if (aColor === 'purple' && bColor === 'pink') return -1; if (bColor === 'purple' && aColor === 'pink') return 1; return a.rank - b.rank; // return sortGeneNames(aName, bName); } function mergeDescriptions(annot, desc, ideo) { let mergedDesc; const descriptions = ideo.annotDescriptions.annots; if (annot.name in descriptions) { const otherDesc = descriptions[annot.name]; mergedDesc = desc; if (desc.type === otherDesc.type) return; Object.keys(otherDesc).forEach(function(key) { if (key in mergedDesc === false) { mergedDesc[key] = otherDesc[key]; } }); // Object.assign({}, descriptions[annot.name]); if ('type' in otherDesc) { mergedDesc.type += ', ' + otherDesc.type; mergedDesc.description += `<br/><br/>${otherDesc.description}`; } } else { mergedDesc = desc; } ideo.annotDescriptions.annots[annot.name] = mergedDesc; } function mergeAnnots(unmergedAnnots) { const seenAnnots = {}; let mergedAnnots = []; unmergedAnnots.forEach((annot) => { if (annot.name in seenAnnots === false) { mergedAnnots.push(annot); seenAnnots[annot.name] = 1; } else { if (annot.color === 'purple') { mergedAnnots = mergedAnnots.map((mergedAnnot) => { return (annot.name === mergedAnnot.name) ? annot : mergedAnnot; }); } } }); return mergedAnnots; } /** Filter, sort, draw annots. Move legend. */ function finishPlotRelatedGenes(type, ideo) { setRelatedAnnotDomIds(ideo); let annots = deepCopy(ideo.relatedAnnots); annots = applyAnnotsIncludeList(annots, ideo); annots = mergeAnnots(annots); // annots = applyRankCutoff(annots, 40, ideo); ideo.relatedAnnots = mergeAnnots(annots); // ideo.relatedAnnots = applyRankCutoff(annots, 40, ideo); // annots.sort(sortByRelatedType); ideo.relatedAnnots.sort(ideo.annotSortFunction); // ideo.relatedAnnots = ideo.relatedAnnots.slice(0, 40); if (annots.length > 1 && ideo.onFindRelatedGenesCallback) { ideo.onFindRelatedGenesCallback(); } ideo.drawAnnots(annots); // const idsToRemove = annots.slice(40).map(a => a.domId); // if (idsToRemove.length > 0) { // const selector = '#' + idsToRemove.join(',#') // document.querySelectorAll(selector).forEach(el => el.remove()); // } if (ideo.config.showAnnotLabels) { ideo.fillAnnotLabels(ideo.relatedAnnots); } moveLegend(); analyzePlotTimes(type, ideo); } /** Fetch position of searched gene, return corresponding annotation */ async function processSearchedGene(geneSymbol, ideo) { const t0 = performance.now(); const data = await fetchGenes(geneSymbol, 'symbol', ideo); if (data.hits.length === 0) { return; } const gene = data.hits.find(hit => { const genomicPos = getGenomicPos(hit, ideo); // omits alt loci return genomicPos && genomicPos.ensemblgene; }); const ensemblId = gene.genomic_pos.ensemblgene; // Assign tooltip content. Much of the content is often retrieved from // the gene cache. In that case, all fields except `name` are fetched // from cache. Occasionally, e.g. often upon the very first search, no // content is yet available from cache. let desc = {description: '', ensemblId, type: 'searched gene'}; if (gene.symbol in ideo.annotDescriptions.annots) { // Most content already set via cache. // `name` will be set via non-blocking part of `fetchGenes`. const oldDesc = ideo.annotDescriptions.annots[gene.symbol]; desc = Object.assign(oldDesc, desc); } else { // No content has been set yet via cache. In this case, `gene` already // has all the data needed for the searched gene's tooltip content. desc.name = gene.name; } ideo.annotDescriptions.annots[gene.symbol] = desc; const annot = parseAnnotFromMgiGene(gene, ideo); ideo.relatedAnnots.push(annot); ideo.time.rg.searchedGene = timeDiff(t0); return annot; } function adjustPlaceAndVisibility(ideo) { var ideoContainerDom = document.querySelector(ideo.config.container); ideoContainerDom.style.visibility = ''; ideoContainerDom.style.position = 'absolute'; ideoContainerDom.style.width = '100%'; var ideoInnerDom = document.querySelector('#_ideogramInnerWrap'); ideoInnerDom.style.position = 'relative'; ideoInnerDom.style.marginLeft = 'auto'; ideoInnerDom.style.marginRight = 'auto'; ideoInnerDom.style.overflowY = 'hidden'; document.querySelector('#_ideogramMiddleWrap').style.overflowY = 'hidden'; const legendPad = ideo.config.legendPad; if (typeof ideo.didAdjustIdeogramLegend === 'undefined') { // Accounts for moving legend when external content at left or right // is variable upon first rendering plotted genes var ideoDom = document.querySelector('#_ideogram'); const legendWidth = 160; ideoInnerDom.style.maxWidth = ( parseInt(ideoInnerDom.style.maxWidth) + legendWidth + legendPad ) + 'px'; ideoDom.style.minWidth = (parseInt(ideoDom.style.minWidth) + legendPad) + 'px'; ideoDom.style.maxWidth = (parseInt(ideoDom.style.minWidth) + legendPad) + 'px'; ideoDom.style.position = 'relative'; ideoDom.style.left = legendWidth + 'px'; ideo.didAdjustIdeogramLegend = true; } } function sortByPathwayIxn(a, b) { const aColor = a.color; const bColor = b.color; // Rank red (searched gene) highest if (aColor === 'red') return -1; if (bColor === 'red') return 1; // Rank not grey above grey if (aColor === 'grey' && bColor !== 'grey') return 1; if (bColor === 'grey' && aColor !== 'grey') return -1; return a.rank - b.rank; } // async function fetchPathwayGeneAnnots(searchedGene, pathway, ideo) { // const annots = []; // const pathwayIxns = // await fetchPathwayInteractions(searchedGene.name, pathway.id, ideo); // const pathwayGenes = Object.keys(pathwayIxns); // const data = await fetchGenes(pathwayGenes, 'symbol', ideo); // const ixnColors = { // 'Stimulates': 'green', // 'Stimulated by': 'green', // 'Necessarily stimulates': 'green', // 'Necessarily stimulated by': 'green', // 'Transcribes / translates': 'brown', // 'Transcribed / translated by': 'brown', // 'Inhibits': 'red', // 'Inhibited by': 'red', // 'Modifies': 'blue', // 'Modified by': 'blue', // 'Acts on': 'blue', // 'Acted on by': 'blue', // 'Catalyzes': 'orange', // 'Catalyzed by': 'orange', // 'Converts': 'orange', // 'Converted by': 'orange', // 'Binds': 'black', // 'Shares pathway with': 'grey' // }; // data.hits.forEach(gene => { // // If hit lacks position // // or is same as searched gene (e.g. search for human SRC), // // then skip processing // if ( // 'genomic_pos' in gene === false || // gene.symbol === searchedGene.name // ) { // return; // } // // Account for edge case: cyclic AMP (cAMP) is not "CAMP" gene // if (gene.symbol === 'cAMP') return; // const summary = pathwayIxns[gene.symbol]; // const color = ixnColors[summary]; // // if (color !== 'blue') console.log(gene); // const annot = parseAnnotFromMgiGene(gene, ideo, color); // annots.push(annot); // const descriptionObj = // describePathwayGene(gene, searchedGene, pathway, summary); // mergeDescriptions(annot, descriptionObj, ideo); // }); // ideo.annotSortFunction = sortByPathwayIxn; // const sortedAnnots = annots.sort(sortByPathwayIxn).slice(0, 40); // return sortedAnnots; // } // /** // * // */ // async function plotPathwayGenes(searchedGene, pathway, ideo) { // const headerTitle = 'Genes in pathway'; // initAnnotDescriptions(ideo, headerTitle); // legendPathwayName = pathway.name; // ideo.config.legend = pathwayLegend; // writeLegend(ideo); // moveLegend(); // ideo.relatedAnnots = []; // await processSearchedGene(searchedGene.name, ideo); // const annots = await fetchPathwayGeneAnnots(searchedGene, pathway, ideo); // ideo.relatedAnnots.push(...annots); // finishPlotRelatedGenes('pathway', ideo); // } function initAnnotDescriptions(ideo, headerTitle) { const organism = ideo.getScientificName(ideo.config.taxid); const version = Ideogram.version; const headers = [ `# ${headerTitle}`, `# Organism: ${organism}`, `# Generated by Ideogram.js version ${version}, https://github.com/eweitz/ideogram`, `# Generated at ${window.location.href}` ].join('\n'); delete ideo.annotDescriptions; ideo.annotDescriptions = {headers, annots: {}}; } /** * For given gene, finds and draws interacting genes and paralogs * * @param geneSymbol {String} Gene symbol, e.g. RAD51 */ async function plotRelatedGenes(geneSymbol=null) { const ideo = this; ideo.clearAnnotLabels(); const legend = document.querySelector('#_ideogramLegend'); if (legend) legend.remove(); if (!geneSymbol) { return plotGeneHints(ideo); } ideo.config = setRelatedDecorPad(ideo.config); initAnnotDescriptions(ideo, `Related genes for ${geneSymbol}`); const ideoSel = ideo.selector; const annotSel = ideoSel + ' .annot'; document.querySelectorAll(annotSel).forEach(el => el.remove()); ideo.startHideAnnotTooltipTimeout(); // Refine style document.querySelectorAll('.chromosome').forEach(chromosome => { chromosome.style.cursor = ''; }); adjustPlaceAndVisibility(ideo); ideo.relatedAnnots = []; // Fetch positon of searched gene const annot = await processSearchedGene(geneSymbol, ideo); if (typeof annot === 'undefined') throwGeneNotFound(geneSymbol, ideo); ideo.config.legend = relatedLegend; writeLegend(ideo); moveLegend(); await Promise.all([ processInteractions(annot, ideo), processParalogs(annot, ideo) ]); ideo.time.rg.total = timeDiff(ideo.time.rg.t0); analyzeRelatedGenes(ideo); if (ideo.onPlotRelatedGenesCallback) ideo.onPlotRelatedGenesCallback(); } function getAnnotByName(annotName, ideo) { var annotByName; ideo.annots.forEach(annotsByChr => { annotsByChr.annots.forEach(annot => { if (annotName === annot.name) { annotByName = annot; } }); }); if (annotByName === null) { annotByName = ideo.annotDescriptions.annots[annotName]; } return annotByName; } // /** // * Manage click on pathway links in annotation tooltips // */ // function managePathwayClickHandlers(searchedGene, ideo) { // setTimeout(function() { // const pathways = document.querySelectorAll('.ideo-pathway-link'); // if (pathways.length > 0 && !ideo.addedPathwayClickHandler) { // pathways.forEach(pathway => { // // pathway.removeEventListener('click', handlePathwayClick); // pathway.addEventListener('click', function(event) { // const target = event.target; // const pathwayId = target.getAttribute('data-pathway-id'); // const pathwayName = target.getAttribute('data-pathway-name'); // const pathway = {id: pathwayId, name: pathwayName}; // plotPathwayGenes(searchedGene, pathway, ideo); // }); // }); // // Ensures handler isn't added redundantly. This is used because // // addEventListener options like {once: true} don't suffice // // ideo.addedPathwayClickHandler = true; // } // }, 100); // } /** * Handles click within annotation tooltip * * Makes clicking link in tooltip behave same as clicking annotation */ function handleTooltipClick(ideo) { // const tooltip = document.querySelector('._ideogramTooltip'); // if (!ideo.addedTooltipClickHandler) { // tooltip.addEventListener('click', () => { // const geneDom = document.querySelector('#ideo-related-gene'); // const annotName = geneDom.textContent; // const annot = getAnnotByName(annotName, ideo); // ideo.onClickAnnot(annot); // }); // // Ensures handler isn't added redundantly. This is used because // // addEventListener options like {once: true} don't suffice // ideo.addedTooltipClickHandler = true; // } const tooltip = document.querySelector('._ideogramTooltip'); if (!ideo.addedTooltipClickHandler) { tooltip.addEventListener('click', (event) => { let geneDom = document.querySelector('#ideo-related-gene'); if (!geneDom) { geneDom = event.target; } const annotName = geneDom.textContent; const annot = getAnnotByName(annotName, ideo); ideo.onClickAnnot(annot); }); // Ensures handler isn't added redundantly. This is used because // addEventListener options like {once: true} don't suffice ideo.addedTooltipClickHandler = true; } } /** Return searched gene from annotation descriptions in Ideogram object */ function getSearchedFromDescriptions(ideo) { return ( Object.entries(ideo.annotDescriptions.annots) .find(([k, v]) => v.type === 'searched gene')[0] ); } /** * Enhance tooltip shown on hovering over gene annotation */ function decorateRelatedGene(annot) { const ideo = this; if ( annot.name === ideo.prevClickedAnnot?.name && ideo.isTooltipCooling ) { // Cancels showing tooltip immediately after clicking gene return null; } const descObj = ideo.annotDescriptions.annots[annot.name]; if ('type' in descObj && descObj.type.includes('interacting gene')) { const pathwayIds = descObj.pathwayIds; // Get symbol of the searched gene, e.g. "PTEN" const searchedGene = getSearchedFromDescriptions(ideo); const gpmls = ideo.gpmlsByInteractingGene[annot.name]; const summary = summarizeInteractions(annot.name, searchedGene, pathwayIds, gpmls); if (summary !== null) { const oldSummary = 'Interacts with'; descObj.description = descObj.description.replace(oldSummary, summary); } } const description = descObj.description.length > 0 ? `<br/>${descObj.description}` : ''; const fullName = descObj.name; const style = 'style="color: #0366d6; cursor: pointer;"'; let fullNameAndRank = fullName; if ('rank' in annot) { const rank = 'Ranked ' + annot.rank + ' in general or scholarly interest'; fullNameAndRank = `<span title="${rank}">${fullName}</span>`; } let originalDisplay = `<span id="ideo-related-gene" ${style}>${annot.name}</span><br/>` + `${fullNameAndRank}<br/>` + `${description}` + `<br/>`; if (annot.name.includes('paralogNeighborhood')) { // Rank 1st highest, then put it last as it already has a triangle // annotation, and is often also labeled. const sortedParalogs = descObj.paralogs.sort((a, b) => a.rank - b.rank); const firstRanked = sortedParalogs.shift(); // Take off first sortedParalogs.push(firstRanked); // Make it last originalDisplay = 'Paralog neighborhood<br/>' + '<br/>' + descObj.description + ':<br/>' + `${sortedParalogs .map(paralog => { let title = ''; if (paralog.fullName) title = paralog.fullName; if (paralog.rank) { const rank = paralog.rank; title += ` &#013;Ranked ${rank} in general or scholarly interest`; } if (title !== '') title = `title="${title}"`; return ( `<span class="ideo-paralog-neighbor" ${title} ${style}'>${ paralog.name }</span>` ); }).join('<br/>')}` + '<br/>'; annot.displayCoordinates = descObj.displayCoordinates; } annot.displayName = originalDisplay; handleTooltipClick(ideo); // managePathwayClickHandlers(annot, ideo); return annot; } const shape = 'triangle'; const legendHeaderStyle = `font-size: 14px; font-weight: bold; font-color: #333;`; const relatedLegend = [{ name: ` <div style="position: relative; left: 30px;"> <div style="${legendHeaderStyle}">Related genes</div> <i>Click gene to search</i> </div> `, nameHeight: 50, rows: [ {name: 'Interacting gene', color: 'purple', shape: shape}, {name: 'Paralogous gene', color: 'pink', shape: shape}, {name: 'Searched gene', color: 'red', shape: shape} ] }]; let legendPathwayName = ''; const pathwayLegend = [{ name: ` <div style="position: relative; left: 30px;"> <div style="${legendHeaderStyle}">Related genes</div> <i>Click gene to search</i> </div> `, nameHeight: 50, rows: [ {name: 'Pathway gene', color: 'blue', shape: shape}, {name: 'Searched gene', color: 'red', shape: shape} ] }]; const citedLegend = [{ name: ` <div style="position: relative; left: 30px;"> <div style="${legendHeaderStyle}">Highly cited genes</div> <i>Click gene to search</i> </div> `, nameHeight: 30, rows: [] }]; /** Sets legendPad for related genes view */ function setRelatedDecorPad(kitConfig) { if (kitConfig.showAnnotLabels) { kitConfig.legendPad = 70; } else { kitConfig.legendPad = 30; } return kitConfig; } /** * Wrapper for Ideogram constructor, with generic "Related genes" options * * This function is made available as a static method on Ideogram. * * @param {Object} config Ideogram configuration object */ function _initRelatedGenes(config, annotsInList) { if (annotsInList !== 'all') { annotsInList = annotsInList.map(name => name.toLowerCase()); } const kitDefaults = { showFullyBanded: false, rotatable: false, legend: relatedLegend, chrBorderColor: '#333', chrLabelColor: '#333', onWillShowAnnotTooltip: decorateRelatedGene, annotsInList: annotsInList, showTools: true, showAnnotLabels: true, showParalogNeighborhoods: true, chrFillColor: {centromere: '#DAAAAA'}, relatedGenesMode: 'related' }; if ('onWillShowAnnotTooltip' in config) { const key = 'onWillShowAnnotTooltip'; const clientFn = config[key]; const defaultFunction = kitDefaults[key]; const newFunction = function(annot) { annot = defaultFunction.bind(this)(annot); annot = clientFn.bind(this)(annot); return annot; }; kitDefaults[key] = newFunction; delete config[key]; } // Override kit defaults if client specifies otherwise let kitConfig = Object.assign(kitDefaults, config); kitConfig = setRelatedDecorPad(kitConfig); const ideogram = new Ideogram(kitConfig); // Called upon completing last plot, including all related genes if (config.onPlotRelatedGenes) { ideogram.onPlotRelatedGenesCallback = config.onPlotRelatedGenes; } // Called upon 1) finding paralogs, and 2) finding interacting genes if (config.onFindRelatedGenes) { ideogram.onFindRelatedGenesCallback = config.onFindRelatedGenes; } ideogram.getTooltipAnalytics = getRelatedGenesTooltipAnalytics; ideogram.annotSortFunction = sortByRelatedType; initAnalyzeRelatedGenes(ideogram); let cacheDir = null; if (config.cacheDir) cacheDir = config.cacheDir; initGeneCache(ideogram.config.organism, ideogram, cacheDir); initParalogCache(ideogram.config.organism, ideogram, cacheDir); initInteractionCache(ideogram.config.organism, ideogram, cacheDir); return ideogram; } function plotGeneHints() { const ideo = this; if (!ideo || 'annotDescriptions' in ideo) return; ideo.annotDescriptions = {annots: {}}; ideo.flattenAnnots().map((annot) => { let description = []; if ('significance' in annot && annot.significance !== 'n/a') { description.push(annot.significance); } if ('citations' in annot && annot.citations !== undefined) { description.push(annot.citations); } description = description.join('<br/><br/>'); ideo.annotDescriptions.annots[annot.name] = { description, name: annot.fullName }; }); adjustPlaceAndVisibility(ideo); moveLegend(); ideo.fillAnnotLabels([]); const container = ideo.config.container; document.querySelector(container).style.visibility = ''; } /** * Wrapper for Ideogram constructor, with generic "Related genes" options * * This function is made available as a static method on Ideogram. * * @param {Object} config Ideogram configuration object */ function _initGeneHints(config, annotsInList) { delete config.onPlotRelatedGenes; if (annotsInList !== 'all') { annotsInList = annotsInList.map(name => name.toLowerCase()); } const annotsPath = getDir('cache/homo-sapiens-top-genes.tsv'); const kitDefaults = { showFullyBanded: false, rotatable: false, legend: citedLegend, chrMargin: -4, chrBorderColor: '#333', chrLabelColor: '#333', onWillShowAnnotTooltip: decorateRelatedGene, annotsInList: annotsInList, showTools: true, showAnnotLabels: true, showParalogNeighborhoods: true, onDrawAnnots: plotGeneHints, annotationsPath: annotsPath, relatedGenesMode: 'hints' }; if ('onWillShowAnnotTooltip' in config) { const key = 'onWillShowAnnotTooltip'; const clientFn = config[key]; const defaultFunction = kitDefaults[key]; const newFunction = function(annot) { annot = defaultFunction.bind(this)(annot); annot = clientFn.bind(this)(annot); return annot; }; kitDefaults[key] = newFunction; delete config[key]; } if ('onDrawAnnots' in config) { const key = 'onDrawAnnots'; const clientFn = config[key]; const defaultFunction = kitDefaults[key]; const newFunction = function() { defaultFunction.bind(this)(); clientFn.bind(this)(); }; kitDefaults[key] = newFunction; delete config[key]; } // Override kit defaults if client specifies otherwise const kitConfig = Object.assign(kitDefaults, config); if (kitConfig.showAnnotLabels) { kitConfig.legendPad = 80; } else { kitConfig.legendPad = 30; } const ideogram = new Ideogram(kitConfig); // Called upon completing last plot, including all related genes if (config.onPlotRelatedGenes) { ideogram.onPlotRelatedGenesCallback = config.onPlotRelatedGenes; } // Called upon 1) finding paralogs, and 2) finding interacting genes if (config.onFindRelatedGenes) { ideogram.onFindRelatedGenesCallback = config.onFindRelatedGenes; } ideogram.getTooltipAnalytics = getRelatedGenesTooltipAnalytics; ideogram.annotSortFunction = sortByRelatedType; initAnalyzeRelatedGenes(ideogram); let cacheDir = null; if (config.cacheDir) cacheDir = config.cacheDir; initGeneCache(ideogram.config.organism, ideogram, cacheDir); initParalogCache(ideogram.config.organism, ideogram, cacheDir); initInteractionCache(ideogram.config.organism, ideogram, cacheDir); return ideogram; } export { _initGeneHints, _initRelatedGenes, plotRelatedGenes, getRelatedGenesByType };