diff --git a/empress/support_files/css/empress.css b/empress/support_files/css/empress.css index 718026fd..9d040bd4 100644 --- a/empress/support_files/css/empress.css +++ b/empress/support_files/css/empress.css @@ -696,3 +696,52 @@ p.side-header button:hover, margin-top: 0; margin-bottom: 0; } + +#metadata-slick-grid-container { + top: 50%; + left: 50%; + position: absolute; + transform: translate(-50%, -50%); + background: #ffffff; + border: double; + z-index: 500; + width: 85%; + max-height: 85%; + z-index: 500; + height: fit-content; +} + +#greyout-empress-screen { + position: fixed; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + background-color: black; + filter: alpha(opacity=30); + opacity: 0.3; + z-index: 499; +} + +.close-button { + background-color: #cc0000; + color: white; + cursor: pointer; + border: none; + text-align: center; + outline: none; + margin: 0; + width: 5%; +} + +.close-button:hover { + background-color: #999; +} + +.close-bar { + background-color: #777; + width: 100%; + display: inline-block; + margin: 0; + padding: 0; +} diff --git a/empress/support_files/js/abstract-observer-pattern.js b/empress/support_files/js/abstract-observer-pattern.js new file mode 100644 index 00000000..5f6739b9 --- /dev/null +++ b/empress/support_files/js/abstract-observer-pattern.js @@ -0,0 +1,60 @@ +define(["underscore"], function (_) { + /** + * @abstract + * @class AbstractObserverPattern + * + * From Wikipedia: https://en.wikipedia.org/wiki/Observer_pattern + * The observer pattern is a software design pattern in which an object, + * named the 'Subject', maintains a list of its dependents, called + * 'Observers',and notifies them automatically of any state changes, usually + * by calling one of their methods. + * + * Any class that inherits AbstractObserverPattern will become a 'Subject'. + * + * @param{String} notifyFunction The function that Subject will call on all + * of its observers. observers must implement + * this function in order to register as an + * observer. + */ + class AbstractObserverPattern { + constructor(notifyFunction) { + if (this.constructor === AbstractObserverPattern) { + throw new Error( + "Abstract class: " + + "AbstractObserverPattern cannot be instantiated." + ); + } + + this.observers = []; + this.notifyFunction = notifyFunction; + } + + /* + * Adds an observer to the observer list. + */ + registerObserver(observer) { + if (!this.hasNotifyFunction(observer)) { + throw new Error( + "Cannot register observer: missing " + this.notifyFunction + ); + } + this.observers.push(observer); + } + + /** + * Notify all observers. + */ + notify(data) { + var scope = this; + _.each(this.observers, function (obs) { + obs[scope.notifyFunction](data); + }); + } + + hasNotifyFunction(observer) { + return typeof observer[this.notifyFunction] === "function"; + } + } + + return AbstractObserverPattern; +}); diff --git a/empress/support_files/js/bp-tree.js b/empress/support_files/js/bp-tree.js index a5de5135..56405d00 100644 --- a/empress/support_files/js/bp-tree.js +++ b/empress/support_files/js/bp-tree.js @@ -301,10 +301,17 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { * @return{Number} The excess at position i * @private */ - BPTree.prototype.excess_ = function (i) { + BPTree.prototype.excess = function (i) { // need to subtract 1 since i starts at 0 // Note: rank(1,i) - rank(0,i) = (2*(rank(1,i)) - i - return 2 * this.r1Cache_[i] - i - 1; + // if (i < 0) { + // return 0; + // } + // return 2 * this.r1Cache_[i] - i - 1; + + // this method is used frequently and thus has been updated + // to use a cached version + return this.eCache_[i]; }; /** @@ -1103,5 +1110,71 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { }; }; + /* + * Finds the minimum excess between i and j. + * + * @param {Number} i The ith index of the bp array. + * @param {Number} j The jth index of the bp array. + * + * @return The miniumn excess between i and j. + */ + BPTree.prototype.rmq = function (i, j) { + if (j < i) { + var temp = j; + j = i; + i = temp; + } + var minK = i; + var minV = this.excess(i); + for (var k = i; k < j + 1; k++) { + var obsV = this.excess(k); + if (obsV < minV) { + minK = k; + minV = obsV; + } + } + return minK; + }; + + /* + * Test to see if i if the ancestor of j. + * + * @param {Number} i The ith index of the bp array. + * @param {Number} j The jth index of the bp array. + * + * @return true if i is the ancestor of j, false otherwise. + */ + BPTree.prototype.isAncestor = function (i, j) { + if (i === j) { + return false; + } + + if (!this.b_[i]) { + i = this.open(i); + } + + return i <= j && j < this.close(i); + }; + + /* + * Finds the lowest common ancestor of i and j + * + * @param {Number} i The ith index of the bp array. + * @param {Number} j The jth index of the bp array. + * + * @return The index of the lca of i and j. + */ + BPTree.prototype.lca = function (i, j) { + if (i === j) { + return i; + } else if (this.isAncestor(i, j)) { + return i; + } else if (this.isAncestor(j, i)) { + return j; + } else { + return this.parent(this.rmq(i, j) + 1); + } + }; + return BPTree; }); diff --git a/empress/support_files/js/canvas-events.js b/empress/support_files/js/canvas-events.js index ecf50ed7..5fbd8004 100644 --- a/empress/support_files/js/canvas-events.js +++ b/empress/support_files/js/canvas-events.js @@ -98,14 +98,14 @@ define(["underscore", "glMatrix", "SelectedNodeMenu"], function ( // update the node selection menu selectedNodeMenu.updateMenuPosition(); }; - + var previousCursorValue = canvas.style.cursor; // stops moving tree when mouse is released var stopMove = function (e) { document.onmouseup = null; document.onmousemove = null; scope.mouseX = null; scope.mouseY = null; - canvas.style.cursor = "default"; + canvas.style.cursor = previousCursorValue; }; // adds the listeners to the document to move tree @@ -116,6 +116,7 @@ define(["underscore", "glMatrix", "SelectedNodeMenu"], function ( scope.mouseY = center - e.clientY; document.onmouseup = stopMove; document.onmousemove = moveTree; + previousCursorValue = canvas.style.cursor; canvas.style.cursor = "none"; }; @@ -137,6 +138,7 @@ define(["underscore", "glMatrix", "SelectedNodeMenu"], function ( // removes the selected node menu if the mouseMove flag is not set var mouseClick = function (e) { + var shiftPressed = e.shiftKey; if (!scope.mouseMove) { // clear old select menu selectedNodeMenu.clearSelectedNode(); @@ -201,12 +203,23 @@ define(["underscore", "glMatrix", "SelectedNodeMenu"], function ( scope.placeNodeSelectionMenu( empress.getNodeInfo(closeNode, "name"), false, - closeNode + closeNode, + shiftPressed ); } } }; + var shiftPress = function (e) { + if (e.shiftKey) { + canvas.style.cursor = "pointer"; + } else { + canvas.style.cursor = "default"; + } + }; + document.onkeydown = shiftPress; + document.onkeyup = shiftPress; + // uncollapses a clade if double clicked on var doubleClick = function (e) { var treeSpace = drawer.toTreeCoords(e.clientX, e.clientY); @@ -419,8 +432,15 @@ define(["underscore", "glMatrix", "SelectedNodeMenu"], function ( CanvasEvents.prototype.placeNodeSelectionMenu = function ( nodeName, moveTree, - nodeKey + nodeKey, + shiftPressed = false ) { + if (shiftPressed) { + this.empress.setSelectedNode(nodeKey); + // this.selectedNodeMenu.setSelectedNodes([nodeKey]); + // this.empress.drawTree(); + return; + } var scope = this; var node; /** diff --git a/empress/support_files/js/drawer.js b/empress/support_files/js/drawer.js index 8b304f99..945b2e52 100644 --- a/empress/support_files/js/drawer.js +++ b/empress/support_files/js/drawer.js @@ -138,6 +138,14 @@ define(["underscore", "glMatrix", "Camera", "Colorer"], function ( s.treeColorBuff = c.createBuffer(); this.treeColorSize = 0; + // buffer object for path coordinates + s.pathCoordBuff = c.createBuffer(); + this.pathCoordSize = 0; + + // buffer object to store path color + s.pathColorBuff = c.createBuffer(); + this.pathColorSize = 0; + // buffer object used to thicken node lines s.thickNodeBuff = c.createBuffer(); this.thickNodeSize = 0; @@ -148,6 +156,9 @@ define(["underscore", "glMatrix", "Camera", "Colorer"], function ( // buffer object for active 'selected' node s.selectedNodeBuff = c.createBuffer(); + // buffer object for active 'selected' node + s.highlightedNodesBuff = c.createBuffer(); + // buffer object for colored clades s.cladeBuff = c.createBuffer(); this.cladeVertSize = 0; @@ -315,6 +326,27 @@ define(["underscore", "glMatrix", "Camera", "Colorer"], function ( this.fillBufferData_(this.sProg_.treeColorBuff, data); }; + /** + * Fills the buffer used to draw the selected path. + * + * @param {Array} data The coordinates [x, y, ...] to fill pathCoordBuff + */ + Drawer.prototype.loadPathCoordsBuff = function (data) { + data = new Float32Array(data); + this.pathCoordSize = data.length / 2; + this.fillBufferData_(this.sProg_.pathCoordBuff, data); + }; + + /** + * Fills the buffer used to draw the selected path. + * + * @param {Array} data The color data to fill pathColorBuff + */ + Drawer.prototype.loadPathColorBuff = function (data) { + data = new Float32Array(data); + this.pathColorSize = data.length; + this.fillBufferData_(this.sProg_.pathColorBuff, data); + }; /** * Fills the buffer used to thicken node lines * @@ -349,6 +381,17 @@ define(["underscore", "glMatrix", "Camera", "Colorer"], function ( this.fillBufferData_(this.sProg_.selectedNodeBuff, data); }; + /** + * Fills the selected node buffer + * + * @param {Array} data The coordinate and color of selected node + */ + Drawer.prototype.loadHightlightedSelectedNodeBuff = function (data) { + data = new Float32Array(data); + this.highlightedNodeSize = data.length / this.VERTEX_SIZE; + this.fillBufferData_(this.sProg_.highlightedNodesBuff, data); + }; + /** * Fills the buffer used to draw nodes * @@ -409,11 +452,20 @@ define(["underscore", "glMatrix", "Camera", "Colorer"], function ( this.bindBuffer(s.selectedNodeBuff, 1, 3); c.drawArrays(gl.POINTS, 0, this.selectedNodeSize); + // draw highlighted node + c.uniform1f(s.pointSize, this.SELECTED_NODE_CIRCLE_DIAMETER); + this.bindBuffer(s.highlightedNodesBuff, 1, 3); + c.drawArrays(gl.POINTS, 0, this.highlightedNodeSize); + c.uniform1i(s.isSingle, 0); this.bindBuffer(s.treeCoordBuff, 2, 2); this.bindBuffer(s.treeColorBuff, 3, 1); c.drawArrays(c.LINES, 0, this.treeCoordSize); + this.bindBuffer(s.pathCoordBuff, 2, 2); + this.bindBuffer(s.pathColorBuff, 3, 1); + c.drawArrays(c.LINES, 0, this.pathCoordSize); + this.bindBuffer(s.thickNodeBuff, 1, 3); c.drawArrays(c.TRIANGLES, 0, this.thickNodeSize); diff --git a/empress/support_files/js/empress.js b/empress/support_files/js/empress.js index fc803003..9f33e522 100644 --- a/empress/support_files/js/empress.js +++ b/empress/support_files/js/empress.js @@ -12,6 +12,7 @@ define([ "LayoutsUtil", "ExportUtil", "TreeController", + "PathSelector", ], function ( _, Camera, @@ -25,7 +26,8 @@ define([ chroma, LayoutsUtil, ExportUtil, - TreeController + TreeController, + PathSelector ) { /** * @class EmpressTree @@ -387,6 +389,12 @@ define([ * for clades in this array that share the same group membership. */ this._group = new Array(this._tree.size + 1).fill(-1); + + this.pathSelector = new PathSelector([ + "Name", + ...this._featureMetadataColumns, + ]); + this.pathSelector.registerObserver(this); } /** @@ -2807,6 +2815,7 @@ define([ throw "Layout " + newLayout + " doesn't have coordinate data."; } } + this.pathSelector.triggerEvent(); }; /** @@ -3792,5 +3801,237 @@ define([ this.redrawBarPlotsToMatchLayout(); }; + /* + * Finds the lowest comman ancestor of the input nodes. + * + * @param{Array} nodes An array of node keys. + * + * @return The node key of the lca to nodes. + */ + Empress.prototype.findLCA = function (nodes) { + var scope = this; + // 1: find least common ancestor. + if (nodes.length === 1) return parseInt(nodes[0]); + var nodeBPIndices = _.map(nodes, function (node) { + return scope._tree.postorderselect(node); + }); + var lcaBPIndx = this._tree.lca(nodeBPIndices[0], nodeBPIndices[1]); + nodeBPIndices.shift(); // removes first nodes + nodeBPIndices.shift(); // removes second node + + while (nodeBPIndices.length > 0) { + lcaBPIndx = this._tree.lca(lcaBPIndx, nodeBPIndices.shift()); + } + var lcaNode = this._tree.postorder(lcaBPIndx); + return lcaNode; + }; + + /* + * Colors the tree by highlighting the patch of nodes to the lca. + * + * @param{Array} nodes An array of node keys. + * @param{Array} lca The node key of the lca. + */ + Empress.prototype.colorLCAPath = function (nodes, lca) { + var lcaBPIndx = this._tree.postorderselect(lca); + var coordsBuff = []; + var colorsBuff = []; + var scope = this; + var color = Colorer.rgbToFloat([64, 200, 64]); + addColorToBuff = () => { + colorsBuff.push(color, color); + colorsBuff.push(color, color); + }; + var addCoordsToBuff = (node, parent) => { + if (this._currentLayout === "Unrooted") { + coordsBuff.push(this.getX(parent), this.getY(parent)); + coordsBuff.push(this.getX(node), this.getY(node)); + addColorToBuff(); + } else if (this._currentLayout === "Rectangular") { + // draw horizontal line from child x, y to parent x, child y + coordsBuff.push(this.getX(node), this.getY(node)); + coordsBuff.push(this.getX(parent), this.getY(node)); + addColorToBuff(); + + // draw vertical line from parent x, y to parent x child y + coordsBuff.push(this.getX(parent), this.getY(parent)); + coordsBuff.push(this.getX(parent), this.getY(node)); + addColorToBuff(); + } else if (this._currentLayout === "Circular") { + // draw arc from parent to child + var pAngle = this.getNodeInfo(parent, "angle"); + var nAngle = this.getNodeInfo(node, "angle"); + var arcDelta = nAngle - pAngle; + + var numSamples = this._numSampToApproximate(arcDelta); + var sampleAngle = arcDelta / numSamples; + var sX = this.getX(parent); + var sY = this.getY(parent); + var x, y; + for (var line = 0; line < numSamples; line++) { + x = + sX * Math.cos(line * sampleAngle) - + sY * Math.sin(line * sampleAngle); + y = + sX * Math.sin(line * sampleAngle) + + sY * Math.cos(line * sampleAngle); + coordsBuff.push(x, y); + + x = + sX * Math.cos((line + 1) * sampleAngle) - + sY * Math.sin((line + 1) * sampleAngle); + y = + sX * Math.sin((line + 1) * sampleAngle) + + sY * Math.cos((line + 1) * sampleAngle); + coordsBuff.push(x, y); + addColorToBuff(); + } + + // draw line connect node x,y to parent arc + coordsBuff.push(this.getX(node), this.getY(node)); + coordsBuff.push(x, y); + addColorToBuff(); + } + }; + for (var node of nodes) { + node = parseInt(node); + if (node === lca) { + continue; + } + + var parentBPIndx = this._tree.parent( + this._tree.postorderselect(node) + ); + var parent = this._tree.postorder(parentBPIndx); + while (parentBPIndx !== lcaBPIndx) { + // add buff info + addCoordsToBuff(node, parent); + + // progress nodes + node = parent; + parentBPIndx = this._tree.parent(parentBPIndx); + parent = this._tree.postorder(parentBPIndx); + } + addCoordsToBuff(node, parent); + } + this._drawer.loadPathCoordsBuff(coordsBuff); + this._drawer.loadPathColorBuff(colorsBuff); + this.drawTree(); + }; + + /* + * Calculates the distance of the path connecting nodes to the lca. + * + * @param{Array} nodes An array of node keys. + * @param{Array} lca The node key of the lca. + * @return The distance. + */ + Empress.prototype.calcLCADistance = function (nodes, lca) { + var dist = { total: 0, tips: 0, int: 0 }; + var lcaBPIndx = this._tree.postorderselect(lca); + + // find unique nodes + var uniqueNodeBPIndices = new Set(); + for (var node of nodes) { + var nodeBPIndx = this._tree.postorderselect(node); + while ( + nodeBPIndx !== lcaBPIndx && + !uniqueNodeBPIndices.has(nodeBPIndx) + ) { + uniqueNodeBPIndices.add(nodeBPIndx); + nodeBPIndx = this._tree.parent(nodeBPIndx); + } + uniqueNodeBPIndices.add(nodeBPIndx); + } + + // sum distance + for (var nodeIdx of uniqueNodeBPIndices) { + var length = this._tree.length(nodeIdx); + dist.total += length; + if (nodeIdx !== lcaBPIndx) { + if (this._tree.isleaf(nodeIdx)) { + dist.tips += length; + } else { + dist.int += length; + } + } + } + dist.total -= this._tree.length(lcaBPIndx); + + var tips = _.filter([...uniqueNodeBPIndices], (node) => { + return this._tree.isleaf(node); + }); + this.pathSelector.setNumNodesOnPath({ + total: uniqueNodeBPIndices.size, + tips: tips.length, + }); + return dist; + }; + + /* + * Attempts to add node to the selected path. If node is already on path, + * then this will remove node. + * + * @param{Number} node A node key + */ + Empress.prototype.setSelectedNode = function (node) { + var name = this.getName(node); + var metadataRow = this._tipMetadata[node] || this._intMetadata[node]; + var metadata = { Name: name }; + var scope = this; + _.each(metadataRow, function (val, i) { + metadata[scope._featureMetadataColumns[i]] = val; + }); + this.pathSelector.addRemoveNode(node, name, metadata); + }; + + Empress.prototype.setSelectedNodes = function (nodes) { + var scope = this; + var names = []; + var metadata = []; + _.each(nodes, (node) => { + var name = scope.getName(node); + var metadataRow = { Name: name }; + var nodeMetadata = + this._tipMetadata[node] || this._intMetadata[node]; + _.each(nodeMetadata, function (val, i) { + metadataRow[scope._featureMetadataColumns[i]] = val; + }); + names.push(name); + metadata.push(metadataRow); + }); + this.pathSelector.setSelectedNodes(nodes, names, metadata); + }; + + /* + * notify function for PathSelector. If nodes contains 2 or more nodes + * then empress will highlight the selected path. Otherwire, empress + * will reset the selected path. + * + * @param{Array} nodes An array of node keys. + */ + Empress.prototype.pathSelectorUpdate = function (nodes) { + var scope = this; + // 3: highlight nodes + var highlightedNodes = _.chain(nodes) + .map(function (node) { + return [scope.getX(node), scope.getY(node), 4182260]; + }) + .flatten() + .value(); + this._drawer.loadHightlightedSelectedNodeBuff(highlightedNodes); + // 1: find lowest common ancestor. + var lcaNode = this.findLCA(nodes); + var lcaBPIndx = this._tree.postorderselect(lcaNode); + + // 2: color branches leading from nodes to lca + this.colorLCAPath(nodes, lcaNode); + + // return distances + var dist = this.calcLCADistance(nodes, lcaNode); + this.pathSelector.setDistance(dist); + this.drawTree(); + }; + return Empress; }); diff --git a/empress/support_files/js/html-table.js b/empress/support_files/js/html-table.js new file mode 100644 index 00000000..cb633fc5 --- /dev/null +++ b/empress/support_files/js/html-table.js @@ -0,0 +1,132 @@ +define(["underscore", "util"], function (_, util) { + class HTMLTable { + constructor(container, tableInfo = undefined) { + this.container = container; + + // create table + this.table = this.container.appendChild( + document.createElement("table") + ); + this.table.style.width = "100%"; + + // table headers (not required) + this.colHeaders = undefined; + this.rowHeaders = undefined; + + /** + * {rowNum | rowHeader: HTML elemement row} + */ + this.tableData = {}; // indexed by row number or row header + + // initialize table + if (tableInfo) { + this.setTableInfo(tableInfo); + } + } + + setTableInfo(tableInfo) { + var scope = this; + + // clear table + util.clearChildHTMLElement(this.table); + + var row; + + var hasColHeader = tableInfo.hasOwnProperty("colHead"); + var hasRowHeader = tableInfo.hasOwnProperty("rowHead"); + + // add header row + if (hasColHeader) { + this.colHeaders = tableInfo.colHead; + var header = this.table.appendChild( + document.createElement("thead") + ); + var hRow = header.appendChild(document.createElement("tr")); + + // add blank cell to make room for row headers + if (hasRowHeader) { + hRow.appendChild(document.createElement("td")); + } + + // add column headers + _.each(this.colHeaders, (colHeader) => { + var cHeaderCell = hRow.appendChild( + document.createElement("td") + ); + cHeaderCell.textContent = colHeader; + cHeaderCell.style["font-weight"] = "bold"; + }); + } + + var tableBody = this.table.appendChild( + document.createElement("tbody") + ); + if (hasRowHeader) { + this.rowHeaders = tableInfo.rowHead; + _.each(this.rowHeaders, (rowHeader) => { + // add row to table + scope.tableData[rowHeader] = tableBody.appendChild( + document.createElement("tr") + ); + + // add header cell to row + var rHeaderCell = scope.tableData[rowHeader].appendChild( + document.createElement("td") + ); + rHeaderCell.textContent = rowHeader; + rHeaderCell.style["font-weight"] = "bold"; + }); + } else { + _.each(_.range(tableInfo.data.length), (rowNum) => { + // add row to table + scope.tableData[rowNum] = tableBody.appendChild( + document.createElement("tr") + ); + }); + } + + this.setTableData(tableInfo.data); + } + + /* + * either array or dict. + */ + setTableData(data) { + var scope = this; + // add data to table + _.each(data, (rowData, indx) => { + _.each(rowData, (item) => { + var dataCell = scope.tableData[indx].appendChild( + document.createElement("td") + ); + dataCell.innerText = item; + }); + }); + } + + // {rowName | rowIndx: {colName | colIndx: val}} + modifyRowVals(data) { + var scope = this; + _.each(data, (rowData, rowIndx) => { + if (scope.rowHeaders) { + rowIndx = _.indexOf(scope.rowHeaders, rowIndx); + } + + // account for column header + if (scope.colHeaders) rowIndx += 1; + _.each(rowData, (item, colIndx) => { + if (scope.colHeaders) { + colIndx = _.indexOf(scope.colHeaders, colIndx); + } + + // account for row header + if (scope.rowHeaders) colIndx += 1; + var dataCell = scope.table.rows[rowIndx].cells[colIndx]; + dataCell.innerText = item; + }); + }); + } + } + + return HTMLTable; +}); diff --git a/empress/support_files/js/metadata-slick-grid-menu.js b/empress/support_files/js/metadata-slick-grid-menu.js new file mode 100644 index 00000000..83261d15 --- /dev/null +++ b/empress/support_files/js/metadata-slick-grid-menu.js @@ -0,0 +1,170 @@ +define([ + "underscore", + "util", + "AbstractObserverPattern", + "SlickGridMenu", + "Colorer", +], function (_, util, AbstractObserverPattern, SlickGridMenu, Colorer) { + class MetadataSlickGridMenu extends SlickGridMenu { + constructor( + metadataCols, + metadataRows, + container, + hideIdCol = false, + idCol = null, + maxTableHeight = 400, + onClick = null, + frozenColumn = 0, + sortCols = false, + placeIdColFirst = false + ) { + // create slick grid container + var slickGridContainer = container.appendChild( + document.createElement("div") + ); + slickGridContainer.style.width = "100%"; + + // need to unhide container otherwise slickgrid will think the + // width of the container is 0 and not setup correctly + container.classList.remove("hidden"); + super( + metadataCols, + metadataRows, + slickGridContainer, + hideIdCol, + idCol, + maxTableHeight, + onClick, + frozenColumn, + sortCols, + placeIdColFirst + ); + this.metadataCols = metadataCols; + this.metadataSlickGridContainer = container; + this.notifyFunction = "metadataSlickGridUpdate"; + this.greyScreen = document.getElementById("greyout-empress-screen"); + var closeBar = this.metadataSlickGridContainer.insertBefore( + document.createElement("div"), + this.metadataSlickGridContainer.firstChild + ); + closeBar.classList.add("close-bar"); + this.closeBtn = closeBar.appendChild( + document.createElement("button") + ); + this.closeBtn.style.float = "right"; + this.closeBtn.innerText = "X"; + this.closeBtn.classList.add("close-button"); + this.closeBtn.onclick = () => { + this.hide(); + }; + + this.titleDiv = this.metadataSlickGridContainer.insertBefore( + document.createElement("div"), + this.container + ); + this.titleDiv.classList.add("legend-title"); + + this.descriptionDiv = this.metadataSlickGridContainer.insertBefore( + document.createElement("div"), + this.container + ); + this.descriptionDiv.style.width = "100%"; + this.descriptionDiv.style.display = "flex"; + this.descriptionDiv.style["justify-content"] = "center"; + + this.descriptionP = this.descriptionDiv.appendChild( + document.createElement("div") + ); + this.descriptionP.classList.add("side-panel-notes"); + this.descriptionP.style.width = "95%"; + + // re-hide the container + this.metadataSlickGridContainer.classList.add("hidden"); + + // sort column and color cells based on similar values + var scope = this; + this.grid.onSort.subscribe(function (e, args) { + var field = args.columnId; + var sign = args.sortAsc ? 1 : -1; + + var uniqueVals = new Set(); + scope.dataView.sort(function (row1, row2) { + var val1 = row1[field], + val2 = row2[field]; + uniqueVals.add(val1); + uniqueVals.add(val2); + if (val1 === undefined) return 1; + if (val2 === undefined) return -1; + return (val1 > val2 ? 1 : -1) * sign; + }); + }); + } + + hide() { + this.metadataSlickGridContainer.classList.add("hidden"); + this.greyScreen.classList.add("hidden"); + } + + show(cols, metadata, style) { + this.metadataSlickGridContainer.classList.remove("hidden"); + this.greyScreen.classList.remove("hidden"); + this.metadataSlickGridContainer.classList.remove("hidden"); + this.greyScreen.classList.remove("hidden"); + this.setStyleInfo(style); + this.setData(metadata); + this.setColumns(cols); + } + + setStyleInfo(style) { + var title, description; + if (style === "all") { + title = "All metadata"; + description = + "This table includes all available feature metadata " + + "for the selected nodes. " + + "Clicking on a column header will sort the features " + + "based on their value for the selected column. " + + "Additionally, features with the same value will be colored the same."; + } else if (style === "same") { + title = "Same metadata"; + description = + "This table only contains feature values present in " + + "at least two features. All unique feature values have been removed. " + + "Clicking on a column header will sort the features " + + "based on their value for the selected column. " + + "Additionally, features with the same value will be colored the same."; + } else if (style === "diff") { + title = "Different metadata"; + description = + "This table only contains the feature values that are present in " + + "a single feature. All non-unique feature values have been removed. " + + "Clicking on a column header will sort the features " + + "based on their value for the selected column. " + + "Additionally, features with the same value will be colored the same."; + } + + this.titleDiv.innerText = title; + this.descriptionP.innerText = description; + } + + addItem(item) { + // again need to enable container otherwise slickgrid will assume + // its width is 0 + this.show(); + // add new item to data view + this.dataView.addItem(item); + this.dataView.refresh(); + + // find new height of grid + var height = Math.min( + this.maxTableHeight, + 25 * (this.dataView.getLength() + 1) + 15 + ); + this.container.style.height = "" + height + "px"; + this.grid.resizeCanvas(); + this.hide(); + } + } + + return MetadataSlickGridMenu; +}); diff --git a/empress/support_files/js/path-selector.js b/empress/support_files/js/path-selector.js new file mode 100644 index 00000000..c3aede39 --- /dev/null +++ b/empress/support_files/js/path-selector.js @@ -0,0 +1,564 @@ +define([ + "underscore", + "util", + "AbstractObserverPattern", + "MetadataSlickGridMenu", + "HTMLTable", +], function ( + _, + util, + AbstractObserverPattern, + MetadataSlickGridMenu, + HTMLTable +) { + class PathSelector extends AbstractObserverPattern { + constructor(cols) { + super("pathSelectorUpdate"); + this.container = document.getElementById("path-selector-div"); + this.nodeLayer = new NodeLayer( + "Selected nodes", + this.container, + this + ); + this.nodeLayer.registerObserver(this); + this.statLayer = new StatLayer("Statistics", this.container); + this.metadataCols = cols; + // nodeInfo - key: nodeid, val: {name: String, metadata: {col1: val1, col2:val2, ...}, selected: Boolean} + this.nodeInfo = {}; + this.metadataGrid = new MetadataSlickGridMenu( + this.metadataCols, + [], + document.getElementById("metadata-slick-grid-container"), + false, + "Name", + Math.floor(window.innerHeight * 0.75), + null, + 0, + true, + true + ); + this.hideLayers(); + } + + triggerEvent() { + if (this.getSelectedNodes().length < 1) { + return; + } + this.notify(this.getSelectedNodes()); + } + + hideLayers() { + this.nodeLayer.hide(); + this.statLayer.hide(); + } + + showLayers() { + this.nodeLayer.show(); + this.statLayer.show(); + } + + toggleLayers() { + if (_.isEmpty(this.nodeInfo)) { + this.hideLayers(); + } else { + this.showLayers(); + } + } + + addRemoveNode(nodeId, nodeName, metadata) { + if (this.nodeInfo.hasOwnProperty(nodeId)) { + this.nodeLayer.removeNode(nodeId); + return; + } + this.nodeLayer.addNode(nodeId, nodeName); + this.nodeInfo[nodeId] = { + name: nodeName, + metadata: metadata, + selected: true, + }; + + // show selected node menu and stats menu + this.toggleLayers(); + this.notify(this.getSelectedNodes()); + } + + setSelectedNodes(nodeIds, nodeNames, metadata) { + this.nodeLayer.removeAllNodes(); + delete this.nodeInfo; + this.nodeInfo = {}; + _.each(nodeIds, (nodeId, indx) => { + if (this.nodeInfo.hasOwnProperty(nodeId)) { + // this.nodeLayer.removeNode(nodeId); + return; + } + this.nodeLayer.addNode(nodeId, nodeNames[indx]); + this.nodeInfo[nodeId] = { + name: nodeNames[indx], + metadata: metadata[indx], + selected: true, + }; + }); + // show selected node menu and stats menu + this.toggleLayers(); + this.notify(this.getSelectedNodes()); + } + + setDistance(distance) { + this.statLayer.setDistance(distance); + } + + setNumNodesOnPath(numNodes) { + this.statLayer.setNumNodesOnPath(numNodes); + } + + layerUpdate(obj) { + var scope = this; + if (obj.remove !== undefined) { + delete this.nodeInfo[obj.remove]; + } + + if (obj.hide !== undefined) { + this.nodeInfo[obj.hide].selected = false; + } + + if (obj.visible !== undefined) { + this.nodeInfo[obj.visible].selected = true; + } + this.toggleLayers(); + this.notify(this.getSelectedNodes()); + } + + showAllMetadata() { + var scope = this; + var selectedNodes = this.getSelectedNodes(); + var metadata = []; + _.each(selectedNodes, function (node) { + metadata.push(scope.nodeInfo[node].metadata); + }); + this.metadataGrid.show(this.metadataCols, metadata, "all"); + } + + showSameMetadata() { + if (this.getSelectedNodes().length <= 1) { + this.showAllMetadata(); + return; + } + + // 1) initialize result + // key: column name, value: {columnValue: [nodeIds]} + var result = {}; + for (var cName of this.metadataCols) { + result[cName] = {}; + } + var nodeId, info, col, val, colInfo, nodes, node, metadata; + + // 2) extract unique values in each column and a list of nodes + // with that value + // iterate over nodes + for ([nodeId, info] of Object.entries(this.nodeInfo)) { + if (!info.selected) continue; + // iterate over columns + for ([col, val] of Object.entries(info.metadata)) { + // get the column object from result + var colVals = result[col]; + + // check if the val has been seen in col + if (colVals.hasOwnProperty(val)) { + // nodeId's value has already been been seen + // so we will just addit to the entry + colVals[val].push(nodeId); + } else { + // nodeId's value for col has not yet been seen + // so we will create a new entry + colVals[val] = [nodeId]; + } + } + } + + // 3) get columns with same values and format metadata to be used + // in slick grid + // sameMetadata - key: nodeId, value: {col1: val1, col2: val2, ...} + var sameMetadata = {}; + var sameCols = ["Name"]; + // iterate over cols + for ([col, colInfo] of Object.entries(result)) { + // iterate over each value in col + for ([val, nodes] of Object.entries(colInfo)) { + // check if multiple nodes shared same value + if (nodes.length > 1) { + for (node of nodes) { + if (!sameMetadata.hasOwnProperty(node)) { + sameMetadata[node] = { + Name: this.nodeInfo[node].name, + }; + } + sameMetadata[node][col] = val; + } + + if (!sameCols.includes(col)) { + sameCols.push(col); + } + } + } + } + + var sameMetadataArray = []; + for ([node, metadata] of Object.entries(sameMetadata)) { + sameMetadataArray.push(metadata); + } + + // 4) show table + this.metadataGrid.show(sameCols, sameMetadataArray, "same"); + } + + showDifferentMetadata() { + if (this.getSelectedNodes().length <= 1) { + this.showAllMetadata(); + return; + } + + // 1) initialize result + // key: column name, value: {columnValue: [nodeIds]} + var result = {}; + for (var cName of this.metadataCols) { + result[cName] = {}; + } + + var nodeId, info, col, val, colInfo, nodes, node, metadata; + + // 2) extract unique values in each column and a list of nodes + // with that value + // iterate over nodes + for ([nodeId, info] of Object.entries(this.nodeInfo)) { + if (!info.selected) continue; + // iterate over columns + for ([col, val] of Object.entries(info.metadata)) { + // get the column object from result + var colVals = result[col]; + + // check if the val has been seen in col + if (colVals.hasOwnProperty(val)) { + // nodeId's value has already been been seen + // so we will just addit to the entry + colVals[val].push(nodeId); + } else { + // nodeId's value for col has not yet been seen + // so we will create a new entry + colVals[val] = [nodeId]; + } + } + } + + // 3) get columns with different values and format metadata to be used + // in slick grid + // diffMetadata - key: nodeId, value: {col1: val1, col2: val2, ...} + var diffMetadata = {}; + var diffCols = ["Name"]; + // iterate over cols + for ([col, colInfo] of Object.entries(result)) { + // iterate over each value in col + for ([val, nodes] of Object.entries(colInfo)) { + // check if multiple nodes shared same value + if (nodes.length === 1) { + node = nodes[0]; + if (!diffMetadata.hasOwnProperty(node)) { + diffMetadata[node] = { + Name: this.nodeInfo[node].name, + }; + } + diffMetadata[node][col] = val; + + if (!diffCols.includes(col)) { + diffCols.push(col); + } + } + } + } + + var diffMetadataArray = []; + for ([node, metadata] of Object.entries(diffMetadata)) { + diffMetadataArray.push(metadata); + } + + // 4) show table + this.metadataGrid.show(diffCols, diffMetadataArray, "diff"); + } + + getSelectedNodes() { + var scope = this; + var selectedNodes = []; + _.each(this.nodeInfo, function (info, nodeId) { + if (info.selected) selectedNodes.push(nodeId); + }); + return selectedNodes; + } + } + + //************************************************************************// + // NodeLayer class // + //************************************************************************// + var uniqueNum = 1; + class NodeLayer extends AbstractObserverPattern { + constructor(title, container, pathSelector) { + super("layerUpdate"); + this.title = title; // "Selected nodes" + this.container = container; // div + this.layerDiv = null; + this.inputs = []; // checkboxes for each node + this.selectedVals = []; + this.pathSelector = pathSelector; + this.nodeRows = []; + + var scope = this; + + // create layer div + this.layerDiv = this.container.appendChild( + document.createElement("div") + ); + + // create border line + this.layerDiv.appendChild(document.createElement("hr")); + + // create checkbox legend title + var legendTitle = this.layerDiv.appendChild( + document.createElement("div") + ); + legendTitle.innerText = this.title; + legendTitle.classList.add("legend-title"); + + // create container for metadata buttons + var p = this.layerDiv.appendChild(document.createElement("p")); + + // button to show metadata fields with same value across all nodes + var button = p.appendChild(document.createElement("button")); + button.innerText = "Same metadata"; + button.setAttribute("style", "margin: 0 auto;"); + button.onclick = () => { + this.pathSelector.showSameMetadata(); + }; + + // button to show all metadata fields + button = p.appendChild(document.createElement("button")); + button.innerText = "All metadata"; + button.setAttribute("style", "margin: 0 auto;"); + button.onclick = () => { + this.pathSelector.showAllMetadata(); + }; + + // button to show metadata fields with diff value across all nodes + button = p.appendChild(document.createElement("button")); + button.innerText = "Different metadata"; + button.setAttribute("style", "margin: 0 auto;"); + button.onclick = () => { + this.pathSelector.showDifferentMetadata(); + }; + + // create checkbox legend div + var chkBoxLegendDiv = this.layerDiv.appendChild( + document.createElement("div") + ); + chkBoxLegendDiv.classList.add("barplot-layer-legend"); + chkBoxLegendDiv.classList.add("legend"); + + // create chcbox div + var legendChkBoxs = chkBoxLegendDiv.appendChild( + document.createElement("div") + ); + + // create checkboxes + this.table = legendChkBoxs.appendChild( + document.createElement("table") + ); + this.table.style.width = "100%"; + this.table.style["table-layout"] = "fixed"; + + // clear all button + } + + addNode(nodeId, nodeName) { + var row = document.createElement("tr"); + row.style.width = "100%"; + row.value = nodeName; + var id = + this.title.replaceAll(" ", "-") + + "-" + + nodeName.replaceAll(" ", "-") + + uniqueNum++; + + // add checkbox + var dataCheck = document.createElement("td"); + dataCheck.style.width = "5%"; + var input = document.createElement("input"); + input.id = id; + input.setAttribute("type", "checkbox"); + input.checked = true; + + this.inputs.push(input); + dataCheck.appendChild(input); + row.appendChild(dataCheck); + + // add checkbox label + var dataLabel = document.createElement("label"); + dataLabel.setAttribute("for", input.id); + dataLabel.innerText = nodeName; + var labelTD = document.createElement("td"); + labelTD.appendChild(dataLabel); + labelTD.style.width = "79%"; + labelTD.style.overflow = "hidden"; + labelTD.style["text-overflow"] = "ellipsis"; + labelTD.style["white-space"] = "nowrap"; + row.appendChild(labelTD); + this.nodeRows[nodeId] = row; + + // add remove button + var removeTd = row.appendChild(document.createElement("td")); + var removeBtn = removeTd.appendChild( + document.createElement("button") + ); + removeBtn.innerText = "Remove"; + + // add click events for checkbox/remove button + var scope = this; + input.onclick = function () { + var obj = {}; + if (this.checked) { + obj = scope.getNotifyObject({ visible: nodeId }); + } else { + obj = scope.getNotifyObject({ hide: nodeId }); + } + scope.notify(obj); + }; + removeBtn.onclick = function () { + scope.removeNode(nodeId); + }; + + // add row to table + this.table.appendChild(row); + + // value is selected by default + this.selectedVals.push(nodeName); + } + + removeNode(nodeId) { + var row = this.nodeRows[nodeId]; + this.table.deleteRow(row.rowIndex); + this.notify(this.getNotifyObject({ remove: nodeId })); + } + + removeAllNodes() { + util.clearChildHTMLElement(this.table); + } + + getNotifyObject(params) { + var obj = { + remove: undefined, + hide: undefined, + visible: undefined, + showAll: undefined, + showSame: undefined, + showDiff: undefined, + }; + + _.each(params, function (val, key) { + obj[key] = val; + }); + + return obj; + } + + hide() { + this.layerDiv.classList.add("hidden"); + } + + show() { + this.layerDiv.classList.remove("hidden"); + } + } + //************************************************************************// + // NodeLayer end // + //************************************************************************// + + //************************************************************************// + // StatLayer class // + //************************************************************************// + class StatLayer { + constructor(title, container) { + this.title = title; // "Selected nodes" + this.container = container; // div + this.layerDiv = null; + this.numTips = 0; + this.numInt = 0; + this.numTotal = 0; + + var scope = this; + + // create layer div + this.layerDiv = this.container.appendChild( + document.createElement("div") + ); + + // create border line + this.layerDiv.appendChild(document.createElement("hr")); + + // create checkbox legend title + var legendTitle = this.layerDiv.appendChild( + document.createElement("div") + ); + legendTitle.innerText = this.title; + legendTitle.classList.add("legend-title"); + + // create stat div + var statDiv = this.layerDiv.appendChild( + document.createElement("div") + ); + statDiv.style.width = "100%"; + + // create stats table + this.table = new HTMLTable(statDiv, { + colHead: ["Nodes on path", "Distance covered"], + rowHead: ["Tips", "Internal nodes", "Total"], + data: { + Tips: [0, 0], + "Internal nodes": [0, 0], + Total: [0, 0], + }, + }); + } + + hide() { + this.layerDiv.classList.add("hidden"); + } + + show() { + this.layerDiv.classList.remove("hidden"); + } + + setDistance(distance) { + distance = _.mapObject(distance, (val, key) => { + return val.toLocaleString(undefined, { + maximumSignificantDigits: 6, + }); + }); + this.table.modifyRowVals({ + Total: { "Distance covered": distance.total }, + Tips: { "Distance covered": distance.tips }, + "Internal nodes": { "Distance covered": distance.int }, + }); + } + + setNumNodesOnPath(numNodes) { + this.table.modifyRowVals({ + Total: { "Nodes on path": numNodes.total }, + Tips: { "Nodes on path": numNodes.tips }, + "Internal nodes": { + "Nodes on path": numNodes.total - numNodes.tips, + }, + }); + } + } + //************************************************************************// + // StatLayer end // + //************************************************************************// + + return PathSelector; +}); diff --git a/empress/support_files/js/side-panel-handler.js b/empress/support_files/js/side-panel-handler.js index 5a50128e..25787537 100644 --- a/empress/support_files/js/side-panel-handler.js +++ b/empress/support_files/js/side-panel-handler.js @@ -668,32 +668,13 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { * https://stackoverflow.com/a/31581206/10730311. */ SidePanel.prototype.populateTreeStats = function () { - // Formats a number with just toLocaleString() (leaving the locale - // unspecified should mean the user's settings are respected). - // For English, at least, this should mean that numbers are formatted - // with commas as thousands separators (e.g. 12,345). - var populateNum = function (htmlID, val, localeOptions) { - document.getElementById(htmlID).textContent = val.toLocaleString( - undefined, - localeOptions - ); - }; - - // Formats a number with toLocaleString(), and also limits - // the number to 4 digits after the decimal point. - var populateFloat = function (htmlID, val) { - populateNum(htmlID, val, { - minimumFractionDigits: 0, - maximumFractionDigits: 4, - }); - }; var stats = this.empress.getTreeStats(); - populateNum("stats-tip-count", stats.tipCt); - populateNum("stats-int-count", stats.intCt); - populateNum("stats-total-count", stats.allCt); - populateFloat("stats-min-length", stats.min); - populateFloat("stats-max-length", stats.max); - populateFloat("stats-avg-length", stats.avg); + util.populateNum("stats-tip-count", stats.tipCt); + util.populateNum("stats-int-count", stats.intCt); + util.populateNum("stats-total-count", stats.allCt); + util.populateFloat("stats-min-length", stats.min); + util.populateFloat("stats-max-length", stats.max); + util.populateFloat("stats-avg-length", stats.avg); }; return SidePanel; diff --git a/empress/support_files/js/slick-grid-menu.js b/empress/support_files/js/slick-grid-menu.js new file mode 100644 index 00000000..53a6132d --- /dev/null +++ b/empress/support_files/js/slick-grid-menu.js @@ -0,0 +1,283 @@ +define([ + "underscore", + "util", + "slickgrid", + "AbstractObserverPattern", +], function (_, util, SlickGrid, AbstractObserverPattern) { + class SlickGridMenu extends AbstractObserverPattern { + constructor( + metadataCols, + metadataRows, + container, + hideIdCol = false, + idCol = null, + maxTableHeight = 400, + onClick = null, + frozenColumn = -1, + sortCols = false, + placeIdColFirst = false + ) { + super("slickGridUpdate"); + + this.container = container; + this.maxTableHeight = maxTableHeight; + var height = Math.min( + maxTableHeight, + 25 * (metadataRows.length + 1) + 15 + ); + this.container.style.height = "" + height + "px"; + // this.metadata = metadata; + this.idCol = idCol; + this.selectedCol = null; + this.hideIdCol = hideIdCol; + this.sortCols = sortCols; + this.placeIdColFirst = placeIdColFirst; + this.colNames = this.sortColumns(metadataCols); + if (this.idCol === null) { + // create unique id col for upload metadata + this.colNames.push("slick-grid-id"); + var id = 0; + for (var row of metadataRows) { + row["slick-grid-id"] = id++; + } + this.idCol = "slick-grid-id"; + } + + // Create the DataView. + var options = { + autosizeColsMode: "FCV", + autosizeColPaddingPx: +0, + viewportSwitchToScrollModeWidthPercent: +100, + autosizeTextAvgToMWidthRatio: +0.75, + frozenColumn: frozenColumn, + }; + + var columns = this.createColumnOptions(); + + // create data view to hold tree metadata + this.dataView = new Slick.Data.DataView(); + + // Pass it as a data provider to SlickGrid. + this.grid = new Slick.Grid( + this.container, + this.dataView, + columns, + options + ); + + // Make the grid respond to DataView change events. + var scope = this; + this.dataView.onRowCountChanged.subscribe(function (e, args) { + scope.grid.updateRowCount(); + scope.grid.render(); + }); + + this.dataView.onRowsChanged.subscribe(function (e, args) { + scope.grid.invalidateRows(args.rows); + scope.grid.render(); + }); + + // This will fire the change events and update the grid. + this.dataView.setItems(metadataRows, this.idCol); + + this.grid.autosizeColumns(); + + this.grid.onHeaderClick.subscribe(function (e, args) { + scope.selectedCol = args.column.name; + scope.grid.invalidateAllRows(); + for (var column of scope.grid.getColumns()) { + if ( + !scope.hideIdCol || + (scope.hideIdCol && column.name !== scope.idCol) + ) { + column.cssClass = ""; + } + } + args.column.cssClass = "highlight-slick-cell"; + scope.grid.render(); + scope.notify({ selectedCol: scope.selectedCol }); + }); + + // when "onBeforeSort" returns false, the "onSort" won't execute (for example a backend server error while calling backend query to sort) + this.grid.onSort.subscribe(function (e, args) { + var field = args.columnId; + var sign = args.sortAsc ? 1 : -1; + + scope.dataView.sort(function (row1, row2) { + var val1 = row1[field], + val2 = row2[field]; + if (val1 === undefined) return 1; + if (val2 === undefined) return -1; + return (val1 > val2 ? 1 : -1) * sign; + }); + }); + + if (onClick !== null) { + this.grid.onClick.subscribe(function (e, args) { + onClick(args.cell, args.row, args.grid); + }); + } + } + + getSelectedCol() { + return this.selectedCol; + } + + getData() { + return this.dataView.getItems(); + } + + getColumnMetadata(col) { + var data = this.getData(); + var metadata = []; + for (var i = 0; i < data.length; i++) { + metadata.push(data[i][col]); + } + return metadata; + } + + getMetadataForColumns(cols) { + var data = this.getData(); + var metadata = []; + for (var i = 0; i < data.length; i++) { + var colMeta = []; + for (var col of cols) { + colMeta.push(data[i][col]); + } + metadata.push(colMeta); + } + return metadata; + } + + getOptions() { + return this.grid.getOptions(); + } + + reset() { + this.grid.resizeCanvas(); + this.selectedCol = null; + } + + getGrid() { + return this.grid; + } + + getColumns() { + var columnNames = []; + var gridCols = this.grid.getColumns(); + for (var col of gridCols) { + var name = col.name; + if ( + !this.hideIdCol || + (this.hideIdCol && name !== this.idCol) + ) { + columnNames.push(name); + } + } + return columnNames; + } + + clearData() { + this.dataView.setItems([]); + this.grid.render(); + } + + createColumnOptions() { + var columns = []; + for (var column of this.colNames) { + columns.push({ + id: column, + name: column, + field: column, + focusable: false, + sortable: true, + autoSize: { + autosizeMode: "CTI", + rowSelectionMode: "FS1", + valueFilterMode: "NONE", + widthEvalMode: "CANV", + ignoreHeaderText: false, + sizeToRemaining: false, + allowAddlPercent: undefined, + rowSelectionCount: 100, + }, + }); + } + + var scope = this; + if (this.hideIdCol) { + var idColNum = _.findIndex(this.colNames, function (col) { + return col === scope.idCol; + }); + if (idColNum !== -1) { + var idCol = columns[idColNum]; + idCol.width = 0; + idCol.minWidth = 0; + idCol.maxWidth = 0; + idCol.cssClass = "hide-slick-cell"; + idCol.headerCssClass = "hide-slick-cell"; + idCol.autoSize = { + autosizeMode: "LCK", + }; + } + } + return columns; + } + + setColumns(metadataCols) { + this.colNames = this.sortColumns(metadataCols); + var columns = this.createColumnOptions(); + this.grid.setColumns(columns); + this.grid.autosizeColumns(); + } + + setData(metadata) { + this.dataView.setItems(metadata, this.idCol); + + this.refreshTableHeight(); + } + + addItem(item) { + // add new item to data view + this.dataView.addItem(item); + this.dataView.refresh(); + + this.refreshTableHeight(); + } + + refreshTableHeight() { + // find new height of grid + var height = Math.min( + this.maxTableHeight, + 25 * (this.dataView.getLength() + 1) + 15 + ); + this.container.style.height = "" + height + "px"; + this.grid.resizeCanvas(); + } + + setCellCss(cellsCss) { + this.grid.invalidateAllRows(); + this.grid.setCellCssStyles("customCss", cellsCss); + this.grid.render(); + } + + sortColumns(cols) { + var columns; + if (this.sortCols) { + columns = util.naturalSort(cols); + } else { + columns = cols; + } + if (this.placeIdColFirst) { + columns = _.filter(columns, (col) => { + return col !== this.idCol; + }); + columns.unshift(this.idCol); + } + + return columns; + } + } + + return SlickGridMenu; +}); diff --git a/empress/support_files/js/tree-controller.js b/empress/support_files/js/tree-controller.js index 42a42d48..c513433a 100644 --- a/empress/support_files/js/tree-controller.js +++ b/empress/support_files/js/tree-controller.js @@ -531,5 +531,54 @@ define([], function () { return this.model.getCladeNodes(cladeRoot); }; + /* + * Test to see if i if the ancestor of j. + * + * @param {Number} i The ith index of the bp array. + * @param {Number} j The jth index of the bp array. + * + * @return true if i is the ancestor of j, false otherwise. + */ + TreeController.prototype.isAncestor = function (i, j) { + var shearedTree = this.model.shearedTree; + var fullTree = this.model.fullTree; + + var nodeI = shearedTree.postorderselect( + this.model.fullToSheared.get(fullTree.postorder(i)) + ); + var nodeJ = shearedTree.postorderselect( + this.model.fullToSheared.get(fullTree.postorder(j)) + ); + + result = shearedTree.isAncestor(nodeI, nodeJ); + return result; + }; + + /* + * Finds the lowest common ancestor of i and j + * + * @param {Number} i The ith index of the bp array. + * @param {Number} j The jth index of the bp array. + * + * @return The index of the lca of i and j. + */ + TreeController.prototype.lca = function (i, j) { + var shearedTree = this.model.shearedTree; + var fullTree = this.model.fullTree; + + var nodeI = shearedTree.postorderselect( + this.model.fullToSheared.get(fullTree.postorder(i)) + ); + var nodeJ = shearedTree.postorderselect( + this.model.fullToSheared.get(fullTree.postorder(j)) + ); + + resultNode = shearedTree.postorder(shearedTree.lca(nodeI, nodeJ)); + resultNode = fullTree.postorderselect( + this.model.shearedToFull.get(resultNode) + ); + return resultNode; + }; + return TreeController; }); diff --git a/empress/support_files/js/util.js b/empress/support_files/js/util.js index 9ada67bc..e2a2844a 100644 --- a/empress/support_files/js/util.js +++ b/empress/support_files/js/util.js @@ -298,6 +298,44 @@ define(["underscore", "toastr"], function (_, toastr) { } } + /* Formats a number with just toLocaleString() (leaving the locale + * unspecified should mean the user's settings are respected). + * For English, at least, this should mean that numbers are formatted + * with commas as thousands separators (e.g. 12,345). + */ + function populateNum(htmlID, val, localeOptions) { + document.getElementById(htmlID).textContent = val.toLocaleString( + undefined, + localeOptions + ); + } + + /* Formats a number with toLocaleString(), and also limits + * the number to 4 digits after the decimal point. + */ + function populateFloat(htmlID, val) { + populateNum(htmlID, val, { + minimumFractionDigits: 0, + maximumFractionDigits: 4, + }); + } + + /** + * removes all child nodes of HTML element + */ + function clearChildHTMLElement(ele) { + while (ele.firstChild) { + _removeChildElement(ele.firstChild); + } + } + + function _removeChildElement(child) { + while (child.hasChildNodes()) { + _removeChildElement(child.firstChild); + } + child.parentNode.removeChild(child); + } + return { keepUniqueKeys: keepUniqueKeys, naturalSort: naturalSort, @@ -307,5 +345,8 @@ define(["underscore", "toastr"], function (_, toastr) { toastMsg: toastMsg, assignBarplotLengths: assignBarplotLengths, removeEmptyArrayKeys: removeEmptyArrayKeys, + populateNum: populateNum, + populateFloat: populateFloat, + clearChildHTMLElement: clearChildHTMLElement, }; }); diff --git a/empress/support_files/templates/empress-template.html b/empress/support_files/templates/empress-template.html index f7172fc9..17674e77 100644 --- a/empress/support_files/templates/empress-template.html +++ b/empress/support_files/templates/empress-template.html @@ -4,6 +4,8 @@