phylotree
Version:
A JavaScript library for developing applications and interactive visualizations involving [phylogenetic trees](https://en.wikipedia.org/wiki/Phylogenetic_tree), written as an extension of the [D3](http://d3js.org) [hierarchy layout](https://github.com/d3/
454 lines (405 loc) • 12.6 kB
JavaScript
import * as d3 from "d3";
import * as _ from "underscore";
import * as events from "./events";
import { isLeafNode } from "../nodes";
import { isNodeCollapsed, hasHiddenNodes } from "./nodes";
import { predefined_selecters } from "./options";
let d3_layout_phylotree_context_menu_id = "d3_layout_phylotree_context_menu";
export function nodeDropdownMenu(node, container, phylotree, options, event) {
let menu_object = d3
.select(container)
.select("#" + d3_layout_phylotree_context_menu_id);
if (menu_object.empty()) {
menu_object = d3
.select(container)
.append("div")
.attr("id", d3_layout_phylotree_context_menu_id)
.attr("class", "dropdown-menu")
.attr("role", "menu");
}
menu_object.selectAll("a").remove();
menu_object.selectAll("h6").remove();
menu_object.selectAll("div").remove();
if (node) {
if (
!_.some([
Boolean(node.menu_items),
options["hide"],
options["selectable"],
options["collapsible"]
]) ||
!options["show-menu"]
)
return;
if (!isLeafNode(node)) {
if (options["collapsible"]) {
menu_object
.append("a")
.attr("class", "dropdown-item")
.attr("tabindex", "-1")
.text(isNodeCollapsed(node) ? "Expand Subtree" : "Collapse Subtree")
.on("click", d => {
menu_object.style("display", "none");
this.toggleCollapse(node).update();
});
if (options["selectable"]) {
menu_object.append("div").attr("class", "dropdown-divider");
menu_object
.append("h6")
.attr("class", "dropdown-header")
.text("Toggle selection");
}
}
if (options["selectable"]) {
menu_object
.append("a")
.attr("class", "dropdown-item")
.attr("tabindex", "-1")
.text("All descendant branches")
.on("click", function(d) {
menu_object.style("display", "none");
phylotree.modifySelection(
phylotree.selectAllDescendants(node, true, true)
);
});
menu_object
.append("a")
.attr("class", "dropdown-item")
.attr("tabindex", "-1")
.text("All terminal branches")
.on("click", function(d) {
menu_object.style("display", "none");
phylotree.modifySelection(
phylotree.selectAllDescendants(node, true, false)
);
});
menu_object
.append("a")
.attr("class", "dropdown-item")
.attr("tabindex", "-1")
.text("All internal branches")
.on("click", function(d) {
menu_object.style("display", "none");
phylotree.modifySelection(
phylotree.selectAllDescendants(node, false, true)
);
});
}
}
if (node.parent) {
if (options["selectable"]) {
menu_object
.append("a")
.attr("class", "dropdown-item")
.attr("tabindex", "-1")
.text("Incident branch")
.on("click", function(d) {
menu_object.style("display", "none");
phylotree.modifySelection([node]);
});
menu_object
.append("a")
.attr("class", "dropdown-item")
.attr("tabindex", "-1")
.text("Path to root")
.on("click", d => {
menu_object.style("display", "none");
this.modifySelection(this.phylotree.pathToRoot(node));
});
if (options["reroot"] || options["hide"]) {
menu_object.append("div").attr("class", "dropdown-divider");
}
}
if (options["reroot"]) {
menu_object
.append("a")
.attr("class", "dropdown-item")
.attr("tabindex", "-1")
.text("Reroot on this node")
.on("click", d => {
menu_object.style("display", "none");
this.phylotree.reroot(node);
this.update();
});
}
if (options["hide"]) {
menu_object
.append("a")
.attr("class", "dropdown-item")
.attr("tabindex", "-1")
.text("Hide this " + (isLeafNode(node) ? "node" : "subtree"))
.on("click", d => {
menu_object.style("display", "none");
this.modifySelection([node], "notshown", true, true)
.updateHasHiddenNodes()
.update();
});
}
}
if (hasHiddenNodes(node)) {
menu_object
.append("a")
.attr("class", "dropdown-item")
.attr("tabindex", "-1")
.text("Show all descendant nodes")
.on("click", function(d) {
menu_object.style("display", "none");
phylotree
.modifySelection(
phylotree.selectAllDescendants(node, true, true),
"notshown",
true,
true,
"false"
)
.updateHasHiddenNodes()
.update();
});
}
// now see if we need to add user defined menus
var has_user_elements = [];
if ("menu_items" in node && typeof node["menu_items"] === "object") {
node["menu_items"].forEach(function(d) {
if (d.length == 3) {
if (!d[2] || d[2](node)) {
has_user_elements.push([d[0], d[1]]);
}
}
});
}
if (has_user_elements.length) {
const show_divider_options = [
options["hide"],
options["selectable"],
options["collapsible"]
];
if (_.some(show_divider_options)) {
menu_object.append("div").attr("class", "dropdown-divider");
}
has_user_elements.forEach(function(d) {
menu_object
.append("a")
.attr("class", "dropdown-item")
.attr("tabindex", "-1")
.text((d[0])(node)) // eslint-disable-line
.on("click", _.partial(d[1], node));
});
}
let tree_container = document.querySelector(container); // eslint-disable-line
let rect = tree_container.getBoundingClientRect();
menu_object
.style("position", "absolute")
.style("left", "" + (event.clientX - rect.x + 12 ) + "px")
.style("top", "" + (event.clientY - rect.y ) + "px")
.style("display", "block");
} else {
menu_object.style("display", "none");
}
}
export function addCustomMenu(node, name, callback, condition) {
if (!("menu_items" in node)) {
node["menu_items"] = [];
}
if (
!node["menu_items"].some(function(d) {
return d[0] == name && d[1] == callback && d[2] == condition;
})
) {
node["menu_items"].push([name, callback, condition]);
}
}
/**
*
* Modify the current selection, via functional programming.
*
* @param {Function} node_selecter A function to apply to each node, which
* determines whether they become part of the current selection. Alternatively,
* if ``restricted-selectable`` mode is enabled, a string describing one of
* the pre-defined restricted-selectable options.
* @param {String} attr (Optional) The selection attribute to modify.
* @param {Boolean} place (Optional) Whether or not ``placenodes`` should be called.
* @param {Boolean} skip_refresh (Optional) Whether or not a refresh is called.
* @param {String} mode (Optional) Can be ``"toggle"``, ``"true"``, or ``"false"``.
* @returns The current ``this``.
*
*/
export function modifySelection(
node_selecter,
attr,
place,
skip_refresh,
mode
) {
attr = attr || this.selection_attribute_name;
mode = mode || "toggle";
// check if node_selecter is a value of pre-defined selecters
if (this.options["restricted-selectable"].length) {
// the selection must be from a list of pre-determined selections
if (_.contains(_.keys(predefined_selecters), node_selecter)) {
node_selecter = predefined_selecters[node_selecter];
} else {
return;
}
}
if (
(this.options["restricted-selectable"] || this.options["selectable"]) &&
!this.options["binary-selectable"]
) {
var do_refresh = false;
if (typeof node_selecter === "function") {
this.links.forEach(function(d) {
let select_me = node_selecter(d);
d[attr] = d[attr] || false;
if (d[attr] != select_me) {
d[attr] = select_me;
do_refresh = true;
d.target[attr] = select_me;
}
});
} else {
node_selecter.forEach(function(d) {
var new_value;
switch (mode) {
case "true":
new_value = true;
break;
case "false":
new_value = false;
break;
default:
new_value = !d[attr];
break;
}
if (d[attr] != new_value) {
d[attr] = new_value;
do_refresh = true;
}
});
this.links.forEach(function(d) {
d[attr] = d.target[attr];
});
}
var counts;
if (do_refresh) {
if (!skip_refresh) {
events.triggerRefresh(this);
}
if (this.countHandler) {
counts = {};
counts[attr] = this.links.reduce(function(p, c) {
return p + (c[attr] ? 1 : 0);
}, 0);
events.countUpdate(this, counts, this.countHandler);
}
if (place) {
this.placenodes();
}
}
} else if (this.options["binary-selectable"]) {
if (typeof node_selecter === "function") {
this.links.forEach(function(d) {
var select_me = node_selecter(d);
d[attr] = d[attr] || false;
if (d[attr] != select_me) {
d[attr] = select_me;
do_refresh = true;
d.target[attr] = select_me;
}
this.options["attribute-list"].forEach(function(type) {
if (type != attr && d[attr] === true) {
d[type] = false;
d.target[type] = false;
}
});
});
} else {
node_selecter.forEach(function(d) {
var new_value;
new_value = !d[attr];
if (d[attr] != new_value) {
d[attr] = new_value;
do_refresh = true;
}
});
this.links.forEach(function(d) {
d[attr] = d.target[attr];
this.options["attribute-list"].forEach(function(type) {
if (type != attr && d[attr] !== true) {
d[type] = false;
d.target[type] = false;
}
});
});
}
if (do_refresh) {
if (!skip_refresh) {
events.triggerRefresh(this);
}
if (this.countHandler()) {
counts = {};
counts[attr] = this.links.reduce(function(p, c) {
return p + (c[attr] ? 1 : 0);
}, 0);
this.countUpdate(this, counts, this.countHandler());
}
if (place) {
this.placenodes();
}
}
}
if (this._selectionCallback && attr != "tag") {
this._selectionCallback(this.getSelection());
}
this.refresh();
this.update();
return this;
}
/**
* Get nodes which are currently selected.
*
* @returns {Array} An array of nodes that match the current selection.
*/
export function getSelection() {
return selectAllDescendants(this.phylotree.getRootNode(), true, true).filter(d => {
return d[this.selection_attribute_name];
});
}
/**
* Select all descendents of a given node, with options for selecting
* terminal/internal nodes.
*
* @param {Node} node The node whose descendents should be selected.
* @param {Boolean} terminal Whether to include terminal nodes.
* @param {Boolean} internal Whther to include internal nodes.
* @returns {Array} An array of selected nodes.
*/
export function selectAllDescendants(node, terminal, internal) {
let selection = [];
function sel(d) {
if (isLeafNode(d)) {
if (terminal) {
if (d != node) selection.push(d);
}
} else {
if (internal) {
if (d != node) selection.push(d);
}
d.children.forEach(sel);
}
}
sel(node);
return selection;
}
/**
* Getter/setter for the selection callback. This function is called
* every time the current selection is modified, and its argument is
* an array of nodes that make up the current selection.
*
* @param {Function} callback (Optional) The selection callback function.
* @returns The current ``_selectionCallback`` if getting, or the current ``this`` if setting.
*/
export function selectionCallback(callback) {
if (!callback) return this._selectionCallback;
this._selectionCallback = callback;
return this;
}