ideogram
Version:
Chromosome visualization for the web
594 lines (500 loc) • 18.5 kB
JavaScript
import {decompressSync, strFromU8} from 'fflate';
import { sortAnnotsByRank } from '../annotations/annotations';
// Definitions for ArrowHead values in WikiPathways GPML
//
// See also: https://discover.nci.nih.gov/mim/formal_mim_spec.pdf
const interactionArrowMap = {
'Arrow': ['acts on', 'acted on by'],
'TBar': ['inhibits', 'inhibited by'],
'mim-binding': ['binds', 'binds'],
'mim-catalysis': ['catalyzes', 'catalyzed by'],
'mim-cleavage': ['cleaves', 'cleaved by'],
'mim-conversion': ['converts', 'converted by'],
// 'mim-covalent-bond': ['covalently binds',
// 'mim-gap': 'MimGap',
'mim-inhibition': ['inhibits', 'inhibited by'],
'mim-modification': ['modifies', 'modified by'],
'mim-necessary-stimulation':
['necessarily stimulates', 'necessarily stimulated by'],
'mim-stimulation': ['stimulates', 'stimulated by'],
'mim-transcription-translation':
['transcribes / translates', 'transcribed / translated by']
};
// Which interactions types to show first, if showing multiple
const rankedInteractionTypes = [
'transcribe',
'cleave',
'convert',
'bind',
'modifie',
'catalyze',
'necessarily stimulate',
'inhibit',
'stimulate',
'act'
];
export function sortInteractionTypes(a, b) {
const ranks = {};
for (let i = 0; i < rankedInteractionTypes.length; i++) {
const rankedIxnType = rankedInteractionTypes[i];
if (rankedIxnType.includes(a)) ranks.a = i;
if (rankedIxnType.includes(b)) ranks.b = i;
}
return ranks.b - ranks.a;
}
/** Determine if all given interactions in *one* pathway are same */
function determineIxnsInPathwayAreSame(ixns, ixnTypeReference) {
let isRefMatch = true;
let thisIsSame = true;
if (ixns.length === 0) return {isRefMatch, thisIsSame};
const thisIxnTypeReference = ixns[0].ixnType.toLowerCase();
ixns.forEach(ixn => {
const ixnType = ixn.ixnType.toLowerCase();
if (ixnType !== ixnTypeReference) {
isRefMatch = false;
}
if (ixnType !== thisIxnTypeReference) {
thisIsSame = false;
}
});
return {isRefMatch, thisIsSame};
}
/**
* Return first valid interaction type from interactions-by-pathway object
*/
function getIxnTypeReference(ixnsByPwid) {
const ixnTypeReference = Object.values(ixnsByPwid).find(ixns => {
return ixns.length > 0 && 'ixnType' in ixns[0];
})[0].ixnType.toLowerCase();
return ixnTypeReference;
}
/**
* Determine whether all given interactions in all given pathways are the same
*/
function setIsSame(enrichedIxns) {
let isSame = true;
const ixnsByPwid = enrichedIxns.ixnsByPwid;
const ixnTypeReference = getIxnTypeReference(ixnsByPwid);
Object.entries(ixnsByPwid).map(([pwid, ixns]) => {
const {isRefMatch, thisIsSame} =
determineIxnsInPathwayAreSame(ixns, ixnTypeReference);
if (!thisIsSame || !isRefMatch) {
isSame = false;
}
enrichedIxns.isSameByPwid[pwid] = thisIsSame;
});
enrichedIxns.isSame = isSame;
return enrichedIxns;
}
/**
* If interactions aren't all exactly the same, then they are often still
* directionally equivalent.
*
* E.g. if gene A both "modifies" and "converts" gene B, then we can summarize
* that as gene A "acts on" gene B, rather than completely reverting to saying
* gene A "interacts with" gene B.
*
*/
function summarizeByDirection(enrichedIxns) {
let isDirectionSame = true;
const leftTypes = []; // "Acts on" types
const rightTypes = []; // "Acted on by" types
Object.values(interactionArrowMap).forEach(directedTypes => {
rightTypes.push(directedTypes[0]);
leftTypes.push(directedTypes[1]);
});
const right = 'Acts on';
const left = 'Acted on by';
const ixnsByPwid = enrichedIxns.ixnsByPwid;
const firstIxnType = getIxnTypeReference(ixnsByPwid);
const isRight = rightTypes.includes(firstIxnType);
const directionReference = isRight ? right : left;
Object.entries(ixnsByPwid).map(([pwid, ixns]) => {
let isPwDirectionSame = true;
if (ixns.length > 0) {
const pwFirstIxnType = ixns[0].ixnType.toLowerCase();
const pwIsRight = rightTypes.includes(pwFirstIxnType);
const pwDirectionReference = pwIsRight ? right : left;
ixns.forEach(ixn => {
const ixnType = ixn.ixnType.toLowerCase();
const thisIsRight = rightTypes.includes(ixnType);
const direction = thisIsRight ? right : left;
enrichedIxns.directionsByPwid[pwid] = direction;
if (direction !== directionReference) {
isDirectionSame = false;
}
if (direction !== pwDirectionReference) {
isPwDirectionSame = false;
}
});
}
enrichedIxns.isDirectionSameByPwid[pwid] = isPwDirectionSame;
});
enrichedIxns.isDirectionSame = isDirectionSame;
if (isDirectionSame === true) {
enrichedIxns.direction = directionReference;
}
return enrichedIxns;
}
/**
* Summarize interactions by direction
*
* @param {String} gene Interacting gene
* @param {String} searchedGene Searched gene
* @param {Array} pathwayIds List of WikiPathways IDs
* @param {Object} gpmls Object of parsed GPML XMLs values, by pathway ID key
* @returns
*/
export function summarizeInteractions(gene, searchedGene, pathwayIds, gpmls) {
let summary = null;
const ixnsByPwid =
detailAllInteractions(gene, searchedGene, pathwayIds, gpmls);
const ixns = ixnsByPwid[pathwayIds[0]];
if (ixns.length > 0) {
let enrichedIxns = {
ixnsByPwid,
isSameByPwid: {}, // If pathway has all same interaction types
isSame: null, // If above is true for all pathways
isDirectionSameByPwid: {}, // If pathway has same ixn direction
isDirectionSame: null, // If above is true for all pathways
directionsByPwid: {}
};
enrichedIxns = setIsSame(enrichedIxns);
if (enrichedIxns.isSame) {
const ixnType = ixns[0].ixnType;
const newIxn = ixnType;
summary = newIxn;
} else {
enrichedIxns = summarizeByDirection(enrichedIxns);
if (enrichedIxns.isDirectionSame) {
summary = enrichedIxns.direction;
} else {
summary = 'Interacts with';
}
}
}
// if (direction !== null) {
// summary = direction;
// }
// const pwidsByIxnType = {};
// Object.entries(ixns).map(([k, v]) => {
// if (!pwidsByIxnType[v.ixnType]) {
// pwidsByIxnType[v.ixnType] = [v.pathwayId];
// } else {
// pwidsByIxnType[v.ixnType].push([v.pathwayId]);
// }
// });
// console.log('pwidsByIxnType')
// console.log(pwidsByIxnType)
// const tpArray = Object.entries(pwidsByIxnType);
// const sortedIndices = sortInteractionTypes(tpArray.map(tp => tp[0]));
// const sortedTpArray =
// sortedIndices.map(sortedIndex => tpArray[sortedIndex]);
// console.log('sortedTpArray')
// console.log(sortedTpArray)
return summary;
}
/**
* Get detailInteractions results for multiple pathways
*
* @param gene Interacting gene
* @param pathwayIds List of WikiPathways IDs
* @ideo ideo Ideogram instance object
*/
export function detailAllInteractions(gene, searchedGene, pathwayIds, gpmls) {
const ixnsByPwid = {};
pathwayIds.map(pathwayId => {
const gpml = gpmls[pathwayId];
const ixns = detailInteractions(gene, searchedGene, gpml);
ixnsByPwid[pathwayId] = ixns;
});
return ixnsByPwid;
}
/** Get IDs and data element objects for searched or interacting gene */
function getMatches(gpml, label) {
const nodes = Array.from(gpml.querySelectorAll(
`DataNode[TextLabel="${label}"]`
));
const genes = nodes.map(node => {
return {
type: 'node',
matchedLabel: label,
textLabel: node.getAttribute('TextLabel'),
graphId: node.getAttribute('GraphId'),
groupRef: node.getAttribute('GroupRef')
};
});
// Get group identifiers
const geneGraphIds = genes.map(g => g.graphId);
const geneGroupRefs = genes.map(g => g.groupRef);
const groupSelectors =
geneGroupRefs.map(ggr => `Group[GroupId="${ggr}"]`).join(',');
let geneGroups = [];
if (groupSelectors !== '') {
const groups = gpml.querySelectorAll(groupSelectors);
geneGroups = Array.from(groups).map(group => {
return {
type: 'group',
matchedLabel: label,
graphId: group.getAttribute('GraphId'),
groupId: group.getAttribute('GroupId')
};
});
}
const geneGroupGraphIds = geneGroups.map(g => g.graphId);
const matchingGraphIds = geneGraphIds.concat(geneGroupGraphIds);
const elements = genes.concat(geneGroups);
return [matchingGraphIds, elements];
}
async function fetchGpml(pathwayId) {
const pathwayFile = `${pathwayId}.xml.gz`;
const gpmlUrl = `https://cdn.jsdelivr.net/npm/ixn2/${pathwayFile}`;
const response = await fetch(gpmlUrl);
const blob = await response.blob();
const uint8Array = new Uint8Array(await blob.arrayBuffer());
const rawGpml = strFromU8(decompressSync(uint8Array));
const gpml = new DOMParser().parseFromString(rawGpml, 'text/xml');
// console.log('gpml:')
// console.log(gpml)
return gpml;
}
/**
* Request compressed GPML files, which contain detailed interaction data, e.g.
* https://cdn.jsdelivr.net/npm/ixn/WP3982.xml.gz
*
* For more easily readable versions, see also:
* - https://www.wikipathways.org/index.php?title=Pathway:WP3982&action=edit
* - https://www.wikipathways.org//wpi/wpi.php?action=downloadFile&type=gpml&pwTitle=Pathway:WP3982
*
* GPML (Graphical Pathway Markup Language) data encodes detailed interaction
* data for biochemical pathways.
*/
export function fetchGpmls(ideo) {
const pathwayIdsByInteractingGene = {};
Object.entries(ideo.annotDescriptions.annots)
.forEach(([annotName, descObj]) => {
if ('type' in descObj && descObj.type.includes('interacting gene')) {
pathwayIdsByInteractingGene[annotName] = descObj.pathwayIds;
}
});
const gpmlsByInteractingGene = {};
Object.entries(pathwayIdsByInteractingGene)
.forEach(([ixnGene, pathwayIds]) => {
gpmlsByInteractingGene[ixnGene] = {};
pathwayIds.map(async pathwayId => {
const gpml = await fetchGpml(pathwayId);
gpmlsByInteractingGene[ixnGene][pathwayId] = gpml;
});
});
ideo.gpmlsByInteractingGene = gpmlsByInteractingGene;
}
/**
* Get interaction object from a GPML graphics XML element
*
* This interaction object connects the searched gene and interacting gene.
*/
function parseInteractionGraphic(graphic, graphIds) {
let interaction = null;
const {searchedGeneGraphIds, matchingGraphIds} = graphIds;
const endGraphRefs = [];
let numMatchingPoints = 0;
let isConnectedToSourceGene = false;
let ixnType = null;
let searchedGeneIndex = null;
Array.from(graphic.children).forEach(child => {
if (child.nodeName !== 'Point') return;
const point = child;
const graphRef = point.getAttribute('GraphRef');
if (graphRef === null) return;
if (matchingGraphIds.includes(graphRef)) {
numMatchingPoints += 1;
endGraphRefs.push(graphRef);
if (searchedGeneGraphIds.includes(graphRef)) {
isConnectedToSourceGene = true;
}
if (point.getAttribute('ArrowHead')) {
const arrowHead = point.getAttribute('ArrowHead');
const isStart = searchedGeneGraphIds.includes(graphRef);
if (searchedGeneIndex === null) {
searchedGeneIndex = isStart ? 0 : 1;
}
ixnType = interactionArrowMap[arrowHead][isStart ? 0 : 1];
}
}
});
if (numMatchingPoints >= 2 && isConnectedToSourceGene) {
if (searchedGeneIndex === null) {
ixnType = 'interacts with';
}
ixnType = ixnType[0].toUpperCase() + ixnType.slice(1);
const interactionGraphId = graphic.parentNode.getAttribute('GraphId');
interaction = {
'interactionId': interactionGraphId,
'endIds': endGraphRefs,
ixnType
};
}
return interaction;
}
/**
* Get all genes in the given pathway GPML
*/
export async function fetchPathwayInteractions(searchedGene, pathwayId, ideo) {
const gpml = await fetchGpml(pathwayId);
// Gets IDs and elements for searched gene and interacting gene, and,
// if they're in any groups, the IDs of those groups
const genes = {};
const nodes = Array.from(gpml.querySelectorAll('DataNode'));
nodes.forEach(node => {
const label = node.getAttribute('TextLabel');
const normLabel = label.toLowerCase();
const isKnownGene = normLabel in ideo.geneCache.nameCaseMap;
if (isKnownGene) {
genes[label] = 1;
}
});
const pathwayGenes = Object.keys(genes);
const pathwayIxns = {};
pathwayGenes.map(gene => {
if (gene === searchedGene) return;
const gpmls = {};
gpmls[pathwayId] = gpml;
const summary = summarizeInteractions(
gene, searchedGene, [pathwayId], gpmls
);
pathwayIxns[gene] = (summary ? summary : 'Shares pathway with');
});
return pathwayIxns;
}
/**
* Fetch GPML for pathway and find ID of Interaction between two genes,
* and the ID of the two DataNodes for each of those interactions.
*
* WikiPathways SVG isn't detailed enough to reliably determine the specific
* interaction elements relating two genes, given only the gene symbols. This
* fetches augmented GPML data for the pathway, and queries it to get only
* interactions between the two genes.
*/
function detailInteractions(interactingGene, searchedGene, gpml) {
// Gets IDs and elements for searched gene and interacting gene, and,
// if they're in any groups, the IDs of those groups
const [searchedGeneGraphIds, se] = getMatches(gpml, searchedGene);
const [interactingGeneGraphIds, ie] = getMatches(gpml, interactingGene);
const elements = {
searchedGene: se,
interactingGene: ie
};
const matchingGraphIds =
searchedGeneGraphIds.concat(interactingGeneGraphIds);
const graphIds = {searchedGeneGraphIds, matchingGraphIds};
// Get interaction objects that connect the searched and interacting genes
const interactions = [];
const graphicsXml = gpml.querySelectorAll('Interaction Graphics');
Array.from(graphicsXml).forEach(graphic => {
const interaction = parseInteractionGraphic(graphic, graphIds);
if (interaction !== null) {
interaction.elements = elements;
interactions.push(interaction);
}
});
return interactions;
}
// export async function fetchInteractionDiagram(annot, descObj, ideo) {
// // Fetch raw SVG for pathway diagram
// const pathwayId = descObj.pathwayIds[0];
// // const baseUrl = 'https://eweitz.github.io/cachome/wikipathways/';
// const baseUrl = 'https://cachome.github.io/wikipathways/';
// // const baseUrl = 'http://localhost/wikipathways/data/';
// const diagramUrl = baseUrl + pathwayId + '.svg';
// const response = await fetch(diagramUrl);
// if (response.ok) {
// // console.log('searchedGene', searchedGene)
// const ixns = await detailInteractions(annot.name, pathwayId, ideo);
// let selectors = `[name=${annot.name}]`;
// let searchedGeneIndex = 0;
// let interactingGeneIndex;
// if (ixns.length > 0) {
// selectors = ixns[0].endIds.map(id => '#' + id).join(',');
// searchedGeneIndex = ixns[0].searchedGeneIndex;
// interactingGeneIndex = (searchedGeneIndex === 0) ? 1 : 0;
// }
// // https://webservice.wikipathways.org/findInteractions?query=ACE2&format=json
// const rawDiagram = await response.text();
// const pathwayDiagram =
// `<div class="pathway-diagram">${rawDiagram}</div>`;
// annot.displayName += pathwayDiagram;
// document.querySelector('#_ideogramTooltip').innerHTML =
// annot.displayName;
// Ideogram.d3.select('svg.Diagram')
// .attr('width', 350)
// .attr('height', 300);
// const viewport = document.querySelector('.svg-pan-zoom_viewport');
// viewport.removeAttribute('style');
// viewport.removeAttribute('transform');
// const matches = document.querySelectorAll(selectors);
// console.log('matches', matches)
// const match0 = matches[searchedGeneIndex]
// const m0 = match0.getCTM();
// const m0Rect = match0.getBoundingClientRect();
// const m0Box = match0.getBBox();
// const m0MinX = m0.e/m0.a;
// const m0MinY = m0.f/m0.d;
// let minX = m0MinX;
// let minY = m0MinY;
// let width;
// let height;
// width = 350;
// height = 300;
// // matches[0].children[0].setAttribute('fill', '#F55');
// match0.children[0].style.fill = '#f55';
// if (matches.length > 1) {
// // console.log('matches.length > 1')
// // matches[1].children[0].setAttribute('fill', '#C4C');
// const match1 = matches[interactingGeneIndex];
// // console.log('match1')
// // console.log(match1)
// match1.children[0].style.fill = '#c4c';
// const m1 = matches[1].getCTM();
// const m1Rect = matches[1].getBoundingClientRect();
// const m1Box = matches[1].getBBox();
// console.log('m0', m0)
// console.log('m1', m1)
// const m1MinX = m1.e/m1.a;
// const m1MinY = m1.f/m1.d;
// // const m1MinX = m1.e/m1.a + m1Rect.width;
// // const m1MinY = m1.f/m1.d - m1Rect.height;
// if (m1MinX < m0MinX) minX = m1MinX;
// if (m1MinY < m0MinY) minY = m1MinY;
// let pairWidth = 0;
// if (m0Rect.left < m1Rect.left) {
// // pairWidth = m1Rect.right - m0Rect.left;
// width += m1Box.width + 40;
// }
// // width += pairWidth;
// // console.log('m0Rect', m0Rect)
// // console.log('m1Rect', m1Rect)
// // console.log('m1MinX', m1MinX)
// // console.log('m0MinX', m0MinX)
// // console.log('m1MinY', m1MinY)
// // console.log('m0MinY', m0MinY)
// // console.log('pairWidth', pairWidth)
// // console.log('width', width)
// // width += Math.abs(m1MinX - m0MinX);
// // height += Math.abs(m1MinY - m0MinY);
// // minX -= 100;
// // minY -= 100;
// // minX -= 150;
// // minY -= 150;
// } else {
// minX -= 150;
// minY -= 150;
// }
// minX = Math.round(minX);
// minY = Math.round(minY);
// width = Math.round(width);
// height = Math.round(height);
// const viewBox = `${minX} ${minY} ${width} ${height}`;
// console.log('viewBox', viewBox);
// document.querySelector('svg.Diagram').setAttribute('viewBox', viewBox);
// }
// }