Repository: vgteam/sequenceTubeMap (js)

Issue:
Add sample name/read group and CIGAR string to read info dialog

Pull request description:
More read info dialog
Fixes #294 

Patch:
diff --git a/src/components/PopUpTrackInfo.js b/src/components/PopUpTrackInfo.js
new file mode 100644
index 0000000..b95df2e
--- /dev/null
+++ b/src/components/PopUpTrackInfo.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from "prop-types";
+import PopupDialog from './PopupDialog.js';
+
+export const PopUpTrackInfo = ({
+  open,
+  attributes,
+  close,
+}) => {
+    return(
+      <div>
+        <PopupDialog open={open} close={close}>
+            <h5>Object Information</h5>
+            <table>
+              <tbody>
+                {/* Node info here */}
+                {(attributes || []).map(function(attribute){
+                  return <tr key={attribute[0]}>
+                      <td style={{fontWeight: 'bold', border: '1px solid black'}}>{attribute[0]}</td>
+                      <td style={{border: '1px solid black'}}>{attribute[1]}</td>
+                    </tr>
+                })}
+              </tbody>
+            </table>
+        </PopupDialog>
+      </div>
+    )
+}
+
+
+export default PopUpTrackInfo;
+
+PopUpTrackInfo.propTypes = {
+  open: PropTypes.bool.isRequired,
+  /* array argument of track attribute pairs containing attribute name as a string and attribute value
+  as a string or number */
+  attributes: PropTypes.arrayOf(
+    PropTypes.arrayOf(
+      PropTypes.oneOfType([PropTypes.string, PropTypes.number])
+    )
+  ),
+  close: PropTypes.func.isRequired,
+}
diff --git a/src/components/PopUpTrackText.js b/src/components/PopUpTrackText.js
deleted file mode 100644
index d4b743c..0000000
--- a/src/components/PopUpTrackText.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import React from 'react';
-import PropTypes from "prop-types";
-import PopupDialog from './PopupDialog.js';
-
-export const PopUpTrackText = ({
-  open,
-  text,
-  close,
-}) => {
-    return(
-      <div>
-        <PopupDialog open={open} close={close}>
-            {/* Node info here */}
-            <h4>Object Information</h4>
-            <p>Name: {text}</p>
-        </PopupDialog>
-      </div>
-    )
-}
-
-
-export default PopUpTrackText;
-
-PopUpTrackText.propTypes = {
-  open: PropTypes.bool.isRequired,
-  text: PropTypes.string.isRequired,
-  close: PropTypes.func.isRequired,
-}
\ No newline at end of file
diff --git a/src/components/TubeMapContainer.js b/src/components/TubeMapContainer.js
index 0245171..b03f9be 100644
--- a/src/components/TubeMapContainer.js
+++ b/src/components/TubeMapContainer.js
@@ -7,7 +7,7 @@ import TubeMap from "./TubeMap";
 import * as tubeMap from "../util/tubemap";
 import { dataOriginTypes } from "../enums";
 import { fetchAndParse } from "../fetchAndParse";
-import PopUpTrackText from "./PopUpTrackText";
+import PopUpTrackInfo from "./PopUpTrackInfo";
 
 class TubeMapContainer extends Component {
   state = {
@@ -99,9 +99,9 @@ class TubeMapContainer extends Component {
     // and will be set to text to display to represent an open dialog.
     // text stores the current value associated with infoDialogContent for this
     // TubeMapContainer instance, so we can have a shorter name for it.
-    let text = this.state.infoDialogContent;
+    let attributes = this.state.infoDialogContent;
     let isOpen;
-    if (text === undefined){
+    if (attributes === undefined){
       isOpen = false;
     } else {
       isOpen = true;
@@ -111,7 +111,7 @@ class TubeMapContainer extends Component {
 
     return (
       <div id="tubeMapContainer">
-        <PopUpTrackText open={isOpen} text={text} close={closePopup} />
+        <PopUpTrackInfo open={isOpen} attributes={attributes} close={closePopup} />
         <div id="tubeMapSVG">
           <TubeMap
             nodes={this.state.nodes}
diff --git a/src/util/tubemap.js b/src/util/tubemap.js
index 50968e3..b07d25a 100644
--- a/src/util/tubemap.js
+++ b/src/util/tubemap.js
@@ -386,7 +386,8 @@ export function setNodeWidthOption(value) {
 }
 
 // sets callback function that would generate React popup of track information. The callback would 
-// accept a string argument to be displayed.
+// accept an array argument of track attribute pairs containing attribute name as a string and attribute value
+// as a string or number, to be displayed.
 export function setInfoCallback(newCallback) {
   config.showInfoCallback = newCallback;
 }
@@ -2596,6 +2597,7 @@ function generateSVGShapesFromPath() {
             xEnd: Math.max(xStart, xEnd),
             yEnd: yStart + track.width - 1,
             color: trackColor,
+            // TODO: This is not actually the index of the track!
             id: track.id,
             name: track.name,
             type: track.type,
@@ -3654,6 +3656,36 @@ function drawLegend() {
     .addEventListener("click", () => changeAllTracksVisibility(false), false);
 }
 
+// Get a non-read input track index by the ID stored in ther d3 objects.
+function getInputTrackIndexByID(trackID) {
+  let index = 0;
+  while (
+    index < inputTracks.length &&
+    inputTracks[index].id !== Number(trackID)
+  ) {
+    index += 1;
+  }
+  // There might not be a track
+  if (index >= inputTracks.length) return;
+  return index;
+}
+
+// Get any track object by ID.
+// Because of reordering of input tracks, the ID doesn't always match the index.
+function getTrackByID(trackID) {
+  if (typeof trackID !== "number") {
+    throw new Error("Track IDs must be numbers");
+  }
+  // We just do a scan.
+  // TODO: index!
+  for (let i = 0; i < tracks.length; i++) {
+    if (tracks[i].id === trackID) {
+      console.log("Found track with ID ", trackID, " at index ", i);
+      return tracks[i];
+    }
+  }
+}
+
 // Highlight track on mouseover
 function trackMouseOver() {
   /* jshint validthis: true */
@@ -3687,14 +3719,11 @@ function nodeMouseOut() {
 function trackDoubleClick() {
   /* jshint validthis: true */
   const trackID = d3.select(this).attr("trackID");
-  let index = 0;
-  while (
-    index < inputTracks.length &&
-    inputTracks[index].id !== Number(trackID)
-  ) {
-    index += 1;
+  const index = getInputTrackIndexByID(trackID);
+  if (index === undefined) {
+    // Must be a read. Skip it.
+    return;
   }
-  if (index >= inputTracks.length) return;
   if (DEBUG) console.log(`moving index: ${index}`);
   moveTrackToFirstPosition(index);
   createTubeMap();
@@ -3702,10 +3731,28 @@ function trackDoubleClick() {
 
 function trackSingleClick() {
   /* jshint validthis: true */
-  const trackName = d3.select(this).attr("trackName");
+  // Get the track ID as a number
+  const trackID = Number(d3.select(this).attr("trackID"));
+  let current_track = getTrackByID(trackID);
+  console.log("Track ", trackID, " is ", current_track);
+  if (current_track === undefined) {
+    console.error("Missing track: ", trackID);
+    return;
+  }
+  let track_attributes = [];
+  track_attributes.push(["Name", current_track.name])
+  if (current_track.type === "read") {
+    track_attributes.push(["Sample Name", current_track.sample_name])
+    track_attributes.push(["Primary Alignment?", current_track.is_secondary ? "Secondary" : "Primary"])
+    track_attributes.push(["Read Group", current_track.read_group])
+    track_attributes.push(["Score", current_track.score])
+    track_attributes.push(["CIGAR string", current_track.cigar_string])
+    track_attributes.push(["Mapping Quality", current_track.mapping_quality])
+  }
   console.log("Single Click");
+  console.log("read path", );
   console.log(config.showInfoCallback)
-  config.showInfoCallback(trackName)
+  config.showInfoCallback(track_attributes)
 }
 
 // show track name when hovering mouse
@@ -3919,6 +3966,73 @@ function compareReadsByLeftEnd2(a, b) {
   return 0;
 }
 
+// converts readPath, a vg Path object expressed as a JS object, to a CIGAR string
+export function cigar_string (readPath) {
+  console.log("readPath mapping:", readPath.mapping)
+  let cigar = [];
+  for (let i = 0; i < readPath.mapping.length; i += 1) {
+    let mapping = readPath.mapping[i]
+    for (let j = 0; j < mapping.edit.length; j += 1) {
+      let edit = mapping.edit[j]
+      // from_length is not 0, and from_length = to_length, this indicates a match
+      if (edit.from_length && edit.from_length === edit.to_length){
+        cigar = append_cigar_operation(edit.from_length, 'M', cigar);
+      }
+      else {
+        // from_length can be 0, and from_length = to_length, this indicates a match
+        if (edit.from_length === edit.to_length){
+          cigar = append_cigar_operation(edit.from_length, 'M', cigar);
+        } 
+        // if from_length > to_length, this indicates a deletion
+        else if (edit.from_length > edit.to_length){
+          const del = edit.from_length - edit.to_length;
+          const eq = edit.to_length;
+          if (eq){
+            cigar = append_cigar_operation(eq, 'M', cigar);
+          }
+          cigar = append_cigar_operation(del, 'D', cigar);
+        } 
+        // if from_length < to_length, this indicates an insertion
+        else if (edit.from_length < edit.to_length){
+          const ins = edit.to_length - edit.from_length;
+          const eq = edit.from_length;
+          if (eq){
+            cigar = append_cigar_operation(eq, 'M', cigar);
+          }
+          cigar = append_cigar_operation(ins, 'I', cigar);
+        }
+        // if to_length is undefined, this indicates a deletion
+        else if (edit.from_length && edit.to_length === undefined){
+          const del = edit.from_length;
+          cigar = append_cigar_operation(del, 'D', cigar);
+        }
+        // if from_length is undefined, this indicates an insertion
+        else if (edit.from_length === undefined && edit.to_length){
+          const ins = edit.to_length;
+          cigar = append_cigar_operation(ins, 'I', cigar);
+        }
+      }
+    }
+  }
+  console.log("cigar string:", cigar.join(""));
+  return cigar.join("");
+}
+
+function append_cigar_operation (length, operator, cigar){
+  let last_operation = cigar[cigar.length - 1];
+  let last_length = cigar[cigar.length - 2];
+  // if duplicate operations, add the two operations and replace the most recent operation with this
+  if (last_operation === operator){
+    let newLength = last_length + length;
+    cigar[cigar.length - 2] = newLength;
+  } 
+  else {
+    cigar.push(length);
+    cigar.push(operator);
+  }
+  return cigar;
+}
+
 // Pull out reads from a server response into tube map internal format.
 // Use myTracks, and idOffset to compute IDs for each read.
 // Assign each read the given sourceTrackID.
@@ -4044,7 +4158,10 @@ export function vgExtractReads(myNodes, myTracks, myReads, idOffset, sourceTrack
 
       track.mapping_quality = read.mapping_quality || 0;
       track.is_secondary = read.is_secondary || false;
-
+      track.sample_name = read.sample_name || null;
+      track.read_group = read.read_group || null;
+      track.cigar_string = cigar_string(read.path);
+      track.score = read.score || 0;
       extracted.push(track);
     }
   }


Test patch:
diff --git a/src/util/tubemap.test.js b/src/util/tubemap.test.js
new file mode 100644
index 0000000..e1d77ed
--- /dev/null
+++ b/src/util/tubemap.test.js
@@ -0,0 +1,214 @@
+import { cigar_string } from "./tubemap";
+
+describe('cigar_string', () => {
+    it('it can handle an edit that is a match and deletion', async () => {
+      const readPath = {
+        mapping: [
+          {
+            edit: [
+              {
+                from_length: 6,
+                to_length: 4
+              }
+            ]
+          }
+        ]
+      }
+      expect(cigar_string(readPath)).toBe("4M2D");
+    });
+
+    it('it can handle an edit that is a match and insertion', async () => {
+      const readPath = {
+        mapping: [
+          {
+            edit: [
+              {
+                from_length: 4,
+                to_length: 6
+              }
+            ]
+          }
+        ]
+      };
+      expect(cigar_string(readPath)).toBe("4M2I");
+    });
+
+    it('it can handle edit with double-digit length values', async () => {
+      const readPath = {
+        mapping: [
+          {
+            edit: [
+              {
+                from_length: 11,
+                to_length: 11
+              },
+              {
+                from_length: 21,
+                to_length: 22,
+                sequence: "AA"
+              },
+              {
+                to_length: 13,
+                sequence: "A"
+              },
+              {
+                from_length: 12,
+                to_length: 10
+              },
+              {
+                from_length: 22
+              }
+            ]
+          },
+          {
+            edit: [
+              {
+                from_length: 16
+              }
+            ]
+          }
+        ]
+      };
+      expect(cigar_string(readPath)).toBe("32M14I10M40D");
+    });
+
+    it('it can handle a mapping with multiple edits', async () => {
+      const readPath = {
+        mapping: [
+          {
+            edit: [
+              {
+                from_length: 8,
+                to_length: 9
+              }
+            ]
+          },
+          {
+            edit: [
+              {
+                from_length: 2,
+                to_length: 1
+              }
+            ]
+          },
+          {
+            edit: [
+              {
+                from_length: 4,
+                to_length: 4
+              }
+            ]
+          },
+          {
+            edit: [
+              {
+                from_length: 18,
+                to_length: 11
+              }
+            ]
+          },
+          {
+            edit: [
+              {
+                from_length: 15,
+                sequence: "C"
+              }
+            ]
+          },
+          {
+            edit: [
+              {
+                from_length: 3,
+                to_length: 4,
+                sequence: "G"
+              }
+            ]
+          }, 
+          {
+            edit: [
+              {
+                from_length: 8,
+                to_length: 9,
+                sequence: "C"
+              }
+            ]
+          }, 
+          {
+            edit: [
+              {
+                from_length: 10,
+                to_length: 10,
+                sequence: "ATCG"
+              }
+            ]
+          },
+          {
+            edit: [
+              {
+                from_length: 15,
+                to_length: 14,
+                sequence: "G"
+              }
+            ]
+          },
+          {
+            edit: [
+              {
+                to_length: 9,
+                sequence: "AAAA"
+              }
+            ]
+          }
+        ]
+      };
+      expect(cigar_string(readPath)).toBe("8M1I1M1D15M22D3M1I8M1I24M1D9I");
+    });
+
+    it('it can handle multiple edits with lengths of 0', async () => {
+      const readPath = {
+        mapping: [
+          {
+            edit: [
+              {
+                from_length: 0,
+                to_length: 0
+              }
+            ]
+          },
+          {
+            edit: [
+              {
+                from_length: 0,
+                to_length: 0
+              }
+            ]
+          },
+          {
+            edit: [
+              {
+                from_length: 0,
+                to_length: 0
+              }
+            ]
+          },
+          {
+            edit: [
+              {
+                from_length: 0,
+                to_length: 0
+              }
+            ]
+          },
+          {
+            edit: [
+              {
+                from_length: 0,
+                sequence: "C"
+              }
+            ]
+          }
+        ]
+      };
+      expect(cigar_string(readPath)).toBe("0M");
+    });
+  });
\ No newline at end of file