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);
//   }
// }