diff --git a/README.md b/README.md index 3490acd..680778b 100644 --- a/README.md +++ b/README.md @@ -101,4 +101,4 @@ Any bug reports, comments and suggestions are highly appreciated. Please An Application Note is in the works. Stay tuned. # Contact -For further support or bug reports please contact us via [email](mailto:jeffgower98@gmail.com). +For further support or bug reports please contact us via [@Jeff](mailto:jeffgower98@gmail.com) or [@Adrian](mailto:faflow456456@gmail.com). diff --git a/matt/app.py b/matt/app.py index 73506e8..ba2be87 100644 --- a/matt/app.py +++ b/matt/app.py @@ -153,7 +153,7 @@ def download(tree_id): file_without.write(tree_without + "\n") file_without.close() - tree_with = Tree(tree_lengths_json).to_newick() + tree_with = Tree(tree_lengths_json, enable_lengths=True).to_newick() path_with = os.path.join(session["working-directory"], (session["session-name"] + "_" if session["session-name"] else "") + "with_lengths.tree") file_with = open(path_with, "w") diff --git a/matt/static/css.css b/matt/static/css.css index c465f5e..77d8569 100644 --- a/matt/static/css.css +++ b/matt/static/css.css @@ -103,6 +103,12 @@ html, body { right: calc(15vw + 156px); } +#compact-button { + position: absolute; + top: 10px; + right: calc(15vw + 203px); +} + #labels-button { position: absolute; top: 10px; diff --git a/matt/static/js.js b/matt/static/js.js index 3365e18..644826a 100644 --- a/matt/static/js.js +++ b/matt/static/js.js @@ -41,6 +41,12 @@ $(function () { let interval; let enableLengths; let alignLabels = true; + let collapsedmap = {}; + let compactlist = []; + let compactmode = false; + let currenttree; + let counter_of_nodes = [] + let label_blacklist = []; // Gets the options initially getOptions(); @@ -397,6 +403,9 @@ $(function () { set_testing(xhr.getResponseHeader("Testing")); } + + + // Enables the undo and redo buttons (depending on edge cases) if (counter_of_trees > 1) { $("#undo-button").prop("disabled", false); @@ -408,6 +417,7 @@ $(function () { $("#redo-button").prop("disabled", true); } + // Saves the first snapshot // TODO This should be toggleable in options if (snapshotTrees.length == 0) { @@ -415,12 +425,30 @@ $(function () { description(trees[counter_of_trees - 1][0], "Original"); } + // Calls the draw function with the chosen tree (with or without branch lengths) - if (typeof xhr !== "undefined" && xhr.getResponseHeader("Length")) { - draw(JSON.parse(trees[counter_of_trees - 1][2])); - } else { - draw(JSON.parse(trees[counter_of_trees - 1][1])); + if(compactmode === false) { + if (typeof xhr !== "undefined" && xhr.getResponseHeader("Length")) { + currenttree = (trees[counter_of_trees - 1][2]); + draw(JSON.parse(trees[counter_of_trees - 1][2])); + } else { + currenttree = (trees[counter_of_trees - 1][1]); + draw(JSON.parse(trees[counter_of_trees - 1][1])); + } + }else{ + //const tempcalc = draw(data).calculateCompactTree(); + let t; + if (typeof xhr !== "undefined" && xhr.getResponseHeader("Length")) { + currenttree = (trees[counter_of_trees - 1][2]); + t = draw().calculateCompactTree(JSON.parse(currenttree),collapsedmap); + draw(t); + } else { + currenttree = (trees[counter_of_trees - 1][1]); + t = draw().calculateCompactTree(JSON.parse(currenttree),collapsedmap); + draw(t); + } } + } /** @@ -476,7 +504,7 @@ $(function () { if (snapshotTrees.find(element => element[0] == trees[counter_of_trees - 1][0])) { // Shows the info modal $("#info-modal-label").text("This snapshot already exists!") - $("#info-modal-body").text("This tree has already been snapshot.") + $("#info-modal-body").text("You already froze this tree topology.") $("#info-modal").modal("show"); return; } @@ -662,8 +690,26 @@ $(function () { * @param data tree informations */ function draw(data) { + + //const collapsedmap = {}; + if(Object.keys(collapsedmap).length === 0){ + data.forEach((n, index) => { + collapsedmap[n["id"]] = {left: false, right: false, left_id: null, right_id: null, label: "Collapsed"+index, + "collapsed-line-Y":null, + }; + label_blacklist.push(collapsedmap[n["id"]]["label"]); + }); + + } + //TODO: Rework this. It works but not really smooth. + if(typeof data === "undefined") return { calculateCompactTree: calculateCompactTree }; + + + + $("#outgroup-button").css("display", "none"); + xBefore = getTransform("x"); yBefore = getTransform("y"); scaleBefore = getTransform("scale"); @@ -727,6 +773,10 @@ $(function () { g = svg.g(); lines = g.g(); + + + + // Draws the frame around the tree g.add(svg.path("M0,0H" + maxX + "V" + maxY + "H0Z").attr({ fill: 'none', @@ -896,6 +946,291 @@ $(function () { toggleOutgroupButton(); } + + /** + * Gets subtree of a givin node + * @param start clicked node + * @returns {*[]} Subtreelist with ids of the subnodes + */ + function getTree(start){ + const children = []; + const stack = [start] + + while(stack.length > 0){ + const a = stack.pop(); + const node = data.find(n => n["id"] === a); + if(node == null) continue; + ["l_child","r_child"].forEach(child => { + const childid = node[child]; + if(childid !== undefined && childid !== null){ + children.push(childid); + stack.push(childid); + } + }); + } + return children.filter(elements => elements); + } + + /** + * Gets subtree of a givin node + * @param start clicked node + * @returns {*[]} Subtreelist with ids of the subnodes + */ + function getTreeCompact(start, source = data){ + const children = []; + const stack = [start] + + while(stack.length > 0){ + const a = stack.pop(); + const node = source.find(n => n["id"] === a); + if(node == null) continue; + ["l_child","r_child"].forEach(child => { + const childid = node[child]; + if(childid !== undefined && childid !== null){ + children.push(childid); + stack.push(childid); + } + }); + } + return children.filter(elements => elements); + } + + + + + /** + * Collapse function. Collapses / Expands a node with the subtree + * @param childitem childnode (l_node / r_node) + * @param start startnode + * @param collapsed_check check if tree at given node is collapsed or not + * @param direct direction of the node (right / left) + */ + function collapse(childitem, start, collapsed_check, direct){ + const sub = getTree(childitem); + const parent = data.find(d => d["id"] === start); + let side; + + if(direct === "right") side = "r_child"; + if(direct === "left") side = "l_child"; + const child = parent[side]; + if(child === null) return; + + const temp = getTree(child); + temp.push(child); + temp.forEach(id =>{ + const clean_lines = svg.selectAll(`[data-collapsed-Line^='${id}']`); + if(clean_lines !== null) clean_lines.forEach(i => i.remove()); + }); + const currentcollapsed = svg.select(`[data-collapsed-Line^='${child}']`); + + if(currentcollapsed !== null){ + svg.selectAll(`[data-collapsed-Line^='${child}']`).remove() + const childsub = getTree(child); + childsub.forEach(c => { + //MERGE: Push all c elements to subtree + if(!sub.includes(c)) sub.push(c); + }); + } + //Resets state for concat collapse + const temptree = getTree(child); + temptree.push(parent[side]); + + temptree.forEach(ci => { + if(collapsedmap[ci]["left"]) collapsedmap[ci]["left"] = false; + if(collapsedmap[ci]["right"]) collapsedmap[ci]["right"] = false; + }); + + // COLAPSE + if(collapsed_check){ + svg.selectAll(`circle[data-id='${childitem}']`).attr({display: "none"}); + minimap.selectAll(`path[data-id='${childitem}']`).attr({display: "none"}); + sub.forEach(child => { + svg.selectAll(`[data-id='${child}']`).attr({display: "none"}); + svg.selectAll(`circle[data-id='${child}']`).attr({display: "none"}); + minimap.selectAll(`path[data-id='${child}']`).attr({display: "none"}); + }); + counter_of_nodes.push(...sub); + draw_collapsed_line(childitem, start, collapsed_check, direct); + if((counter_of_nodes.length > 0)) { + $("#compact-button").prop("disabled", false); + }else{ + $("#compact-button").prop("disabled", true); + } + + + //EXPAND + }else{ + svg.selectAll(`circle[data-id='${childitem}']`).attr({display: "inline"}); + minimap.selectAll(`path[data-id='${childitem}']`).attr({display: "inline"}); + sub.forEach(child => { + svg.selectAll(`[data-id='${child}']`).attr({display: "inline"}); + svg.selectAll(`circle[data-id='${child}']`).attr({display: "inline"}); + minimap.selectAll(`path[data-id='${child}']`).attr({display: "inline"}); + counter_of_nodes = counter_of_nodes.filter(x=> x !== child); + }); + + + draw_collapsed_line(childitem, start, collapsed_check, direct); + if((counter_of_nodes.length > 0)) { + $("#compact-button").prop("disabled", false); + }else{ + $("#compact-button").prop("disabled", true); + } + + + } + } + + /** + * Draws collapsed related stuff. + * @param childitem childnode (l_node / r_node) + * @param start startnode + * @param collapsed_check check if tree at given node is collapsed or not + * @param direct direction of the node (right / left) + */ + function draw_collapsed_line(childitem, start, collapsed_check, direct){ + + const circle = svg.select(`circle[data-id='${start}'][data-direct='${direct}']`); + const sub = getTree(childitem); + const item_counter = sub.filter(id => { + const n = data.find(d => d["id"] === id); + return n["name"] !== "None"; + }).length; + + const item_name_container = sub. + map(id=>data.find(d => d["id"] === id)). + filter(n => n["name"] !== "None"). + map(n => n["name"]); + const preview_containing = item_name_container.slice(0,10).join(", "); + + let coll_line = null; + let nameText = null; + + if(collapsed_check){ + nameText = svg.text(maxX - offset, circle.attr("data-v"), "'"+collapsedmap[childitem]["label"]+"'"+ " (" + item_counter+ ")").attr({ + dominantBaseline: 'middle', + fontSize: fontSize, + fill: "black", + display: "inline", + "data-collapsed-Line": `${start}-${direct}`, + textAnchor: 'end', + fontStyle: "italic" + }); + g.add(nameText); + + coll_line = svg.line(circle.attr("data-h"), circle.attr("data-v"), maxX - (1.5 * offset) - Math.ceil(nameText.getBBox().width), circle.attr("data-v")).attr({ + stroke: "black", + display: "inline", + "data-collapsed-Line": `${start}-${direct}`, + strokeWidth: 2, + }); + g.add(coll_line); + + + nameText.hover( + function (){ + this.node.style.cursor = "pointer"; + this.attr({fill:"#1e90ff"}); + const box = this.getBBox(); + + const previewlist = preview_containing.split(",").map(x => x.trim()).slice(0,10); + let lines = []; + lines.push("This collapsed taxa "+"contains "+item_counter+" taxa:"); + lines.push(" ") + previewlist.forEach(taxa => lines.push("- "+taxa)); + lines.push(" "); + if(item_counter > 10){ + lines.push(" ... ( "+ (item_counter-10)+ " More )"); + }else{ + lines.push(" ... ( 0 More )"); + } + let text = svg.text(box.x + box.width+10, box.y + box.height /2, lines).attr({ + fill: "#171515", + "collapse-hover-id": start + }); + + text.selectAll("tspan").forEach((tspan, i) =>{ + let dyc; + if(i === 0){ + dyc = 0; + }else{ + dyc = 16; + } + tspan.attr({ + x : box.x + box.width+10, + dy: dyc + }); + }); + const textbox = text.getBBox(); + const desc = svg.rect(textbox.x - 5, textbox.y-3,textbox.width+80, textbox.height+25,5,5).attr({ + strokeWidth: 0.5, + fill: "#bdbdbd", + stroke: "black", + "collapse-hover-id": start + }); + + g.add(desc); + g.add(text); + + }, + function (){ + this.node.style.cursor = "default"; + this.attr({fill:"#000000"}); + svg.selectAll(`[collapse-hover-id='${start}']`).remove(); + } + ); + nameText.click(function () { + const box = this.getBBox(); + const ctm = this.node.getScreenCTM(); + const point = svg.node.createSVGPoint(); + point.x = box.x; + point.y = box.y; + const spoint = point.matrixTransform(ctm); + const input = document.createElement("input"); + input.classList.add("svg-edit-input"); + input["type"] = "text"; + input["value"] = "New label(Not empty)"; + input["style"]["position"] = "absolute"; + input["style"]["left"] = `${spoint.x}px`; + input["style"]["top"] = `${spoint.y}px`; + document.body.append(input); + input.focus(); + + input.addEventListener("keydown", e=>{ + if(e.key === "Enter"){ + if(input["value"] !== ""){ + const newtext = input["value"]; + if(label_blacklist.includes(newtext)){ + $("#info-modal-label").text("Warning") + $("#info-modal-body").text("Please use a different name for your label that hasn't been already used."); + $("#info-modal").modal("show"); + return; + } + + label_blacklist.push(newtext); + label_blacklist = label_blacklist.filter(x=> x !== collapsedmap[childitem]["label"]); + collapsedmap[childitem]["label"] = newtext; + + this.attr({ + text: "\""+newtext+"\"" + "("+item_counter+")", + fontStyle: "italic" + }) + const newlinelength = Math.ceil(nameText.getBBox().width); + coll_line.attr({ + x2: maxX - (1.5 * offset) - newlinelength + }); + input.remove(); + } + } + }); + + }); + }else{ + svg.selectAll(`[data-collapsed-Line='${start}-${direct}']`).remove() + } + } + + // Draws the branches and texts data.forEach(function (item, index, array) { // Text @@ -903,13 +1238,13 @@ $(function () { // TODO draw all texts at the right // with path stroke dasharray //g.add(svg.text(item["total_length"] * scaleX + (1.5 * offset), (index + 1) * scaleY, item["name"]).attr({dominantBaseline: 'middle', fontSize: fontSize, 'data-id': item["id"]})); - // Draw at the far right with a dashed line if (alignLabels) { nameText = svg.text(maxX - offset, (index + 1) * scaleY, item["name"]).attr({ dominantBaseline: 'middle', fontSize: fontSize, 'data-id': item["id"], + 'data-parent': item["parent"], textAnchor: 'end' }); g.add(nameText); @@ -918,7 +1253,9 @@ $(function () { fill: 'none', stroke: 'black', strokeWidth: 2, - strokeDasharray: 4 + strokeDasharray: 4, + "data-id": item["id"], + 'data-parent': item["parent"] })); } // Draw next to the leaf without a dashed line @@ -926,10 +1263,12 @@ $(function () { nameText = svg.text(item["total_length"] * scaleX + (1.5 * offset), (index + 1) * scaleY, item["name"]).attr({ dominantBaseline: 'middle', fontSize: fontSize, - 'data-id': item["id"] + 'data-id': item["id"], + 'data-parent': item["parent"] }); g.add(nameText); } + // Branch } else { // Draw bootstrap on the line if provided @@ -939,7 +1278,8 @@ $(function () { g.add(svg.text((parseFloat(array[parent]["total_length"]) + (item["length"] / 2)) * scaleX, (index + 1) * scaleY, item["bootstrap"]).attr({ dominantBaseline: 'baseline', fontSize: 0.5 * fontSize, - 'data-id': item["id"] + 'data-id': item["id"], + 'data-parent': item["parent"] })); // TODO data-id added graying out of child bootstraps but not selected bootstrap } @@ -971,8 +1311,97 @@ $(function () { "data-id": array[r_child]["id"], "data-parent": item["id"] }); + g.add(left, right); + + + // NODE FOR COLLAPSE / SIMPLIFICATION + const r_edge = svg.circle(hRight, vRight, 8).attr({ + fill: "black", + stroke: "black", + strokeWidth: 2, + "data-id": item["id"], + "data-h": hRight, + "data-v": vRight, + "data-direct": "right", + }); + + // NODE FOR COLLAPSE / SIMPLIFICATION + const l_edge = svg.circle(hLeft, vLeft, 8).attr({ + fill: "black", + stroke: "black", + strokeWidth: 2, + "data-id": item["id"], + "data-h": hLeft, + "data-v": vLeft, + "data-direct": "left", + }); + + if(compactmode === true){ + if((collapsedmap[item["id"]] && collapsedmap[item["id"]]["right_id"] === item["r_child"])){ + r_edge.attr({display: "none"}); + }else{ + r_edge.attr({display: "inline"}); + } + if((collapsedmap[item["id"]] && collapsedmap[item["id"]]["left_id"] === item["l_child"])){ + l_edge.attr({display: "none"}); + }else{ + l_edge.attr({display: "inline"}); + } + } + + l_edge.hover( + function () { + this.node.style.cursor = "pointer"; + + }, function () { + this.node.style.cursor = "default"; + }); + r_edge.hover( + function () { + this.node.style.cursor = "pointer"; + + + }, function () { + this.node.style.cursor = "default"; + + }); + + // Leave isn't leave => check one stage before and delete nodes + const r_child_check = data.find(d => d["id"] === item["r_child"]); + const l_child_check = data.find(d => d["id"] === item["l_child"]); + + + r_edge.click(function(){ + const check = compactlist.find(n => n === item["r_child"]); + if(compactmode === true && (check !== undefined)) return; + document.querySelectorAll(".svg-edit-input").forEach(e => e.remove()); + collapsedmap[item["id"]]["collapsed-line-Y"] = r_edge.attr("data-v"); + collapse(item["r_child"],item["id"], !collapsedmap[item["id"]]["right"], "right"); + collapsedmap[item["id"]]["right"] = !collapsedmap[item["id"]]["right"]; + collapsedmap[item["id"]]["right_id"] = item["r_child"]; + + }); + l_edge.click(function(){ + const check = compactlist.find(n => n === item["l_child"]); + if(compactmode === true && (check !== undefined)) return; + document.querySelectorAll(".svg-edit-input").forEach(e => e.remove()); + collapsedmap[item["id"]]["collapsed-line-Y"] = l_edge.attr("data-v"); + collapse(item["l_child"],item["id"], !collapsedmap[item["id"]]["left"], "left"); + collapsedmap[item["id"]]["left"] = !collapsedmap[item["id"]]["left"]; + collapsedmap[item["id"]]["left_id"] = item["l_child"]; + + }); + g.add(r_edge, l_edge); + + //Removing false leaves + if((r_child_check["name"] !== "None")) r_edge.remove(); + if((l_child_check["name"] !== "None")) l_edge.remove(); + + + + // Repeat almost everything for the minimap mXMinimap = minimapMinX + mX / ratio; mYLeftMinimap = minimapMinY + mYLeft / ratio; @@ -986,15 +1415,33 @@ $(function () { minimapPaths = [minimapLeft, minimapRight]; + + minimapPaths.forEach(function (itemMinimapPath, indexMinimapPath, arrayMinimapPath) { + let data_child; + if(indexMinimapPath === 0){ + collapsedmap[item["id"]]["minimap-l-m"] = itemMinimapPath.attr("d"); + data_child = item["l_child"]; + } + if(indexMinimapPath === 1){ + collapsedmap[item["id"]]["minimap-r-m"] = itemMinimapPath.attr("d"); + data_child = item["r_child"]; + } + + + itemMinimapPath.attr({ fill: 'none', stroke: 'black', strokeWidth: strokeWidth / ratio, - strokeLinecap: 'square' + strokeLinecap: 'square', + "data-id": item["id"], + "data-child": data_child // r or l child + }); }); + if (array[l_child]["bootstrap"] != "") { left.append(Snap.parse('