import { dom, events, canvas, constants } from './utils';
import Branch from './Branch';
import { ChildNodesTooltip as Tooltip } from './Tooltip';
import treeTypes from './treeTypes';
import parsers from './parsers';
const { addClass, setCursorDrag, setCursorDragging } = dom;
const { fireEvent, addEvent, removeEvent } = events;
const { getPixelRatio, translateClick } = canvas;
const { Predicates } = constants;
/**
* A Phylocanvas instance.
*
* @class
* @see module:Phylocanvas~createTree
*/
class Tree {
/**
* @constructor
* @param {string|HTMLElement} element
* @param {Object} config
*/
constructor(element, config = {}) {
this._point = { x: 0, y: 0 };
/**
* Places the instance in the DOM. Events are triggered from here.
*
* @type HTMLElement
*/
this.containerElement =
(typeof element === 'string' ? document.getElementById(element) : element);
addClass(this.containerElement, 'pc-container');
/**
* Dictionary of {@link Branch} objects indexed by ID.
*
* @type {Object.<string, Branch>}
*/
this.branches = {};
/**
* List of leaves.
*
* @type Array.<Branch>
*/
this.leaves = [];
/**
* The root node of the tree
* (not neccesarily a root in the Phylogenetic sense)
*
* @type Branch
*/
this.root = false;
/**
* Stores the unparsed tree.
*
* @type string
*/
this.stringRepresentation = '';
/**
* Colour the branches of the tree based on the colour of the tips.
*
* @type boolean
*/
this.backColour = false;
/**
* Stores the state of the tree after parsing.
*
* @type Object
*/
this.originalTree = {};
// Set up the element and canvas
if (window.getComputedStyle(this.containerElement).position === 'static') {
this.containerElement.style.position = 'relative';
}
this.containerElement.style.boxSizing = 'border-box';
const canvasElement = document.createElement('canvas');
canvasElement.id = (this.containerElement.id || '') + '__canvas';
canvasElement.className = 'phylocanvas';
canvasElement.style.position = 'relative';
canvasElement.height = element.offsetHeight || 400;
canvasElement.width = element.offsetWidth || 400;
canvasElement.style.zIndex = '1';
this.containerElement.appendChild(canvasElement);
/**
* Canvas drawing context.
*
* @type CanvasRenderingContext2D
*/
this.canvas = canvasElement.getContext('2d');
this.canvas.canvas.onselectstart = function () { return false; };
this.canvas.fillStyle = '#000000';
this.canvas.strokeStyle = '#000000';
this.canvas.save();
/**
* Colour for collapsed sections of the tree.
*
* @type string
*/
this.collapsedColour = 'rgba(0, 0, 0, 0.5)';
/**
* A minimum and maximum number of child branches within which to
* automatically collapse branches on the first draw.
*
* @type object
* @property {number} min
* @property {number} max
*/
this.defaultCollapsed = {};
/**
* The default tooltip showing number of child branches.
*
* @type Tooltip
*/
this.tooltip = new Tooltip(this);
/**
* Has Tree been drawn already, true after first draw.
*
* @type boolean
*/
this.drawn = false;
/**
* Stores highlighting functions used during drawing.
*
* @type Array.<Function>
*/
this.highlighters = [];
/**
* The current level of zoom.
*
* @type number
*/
this.zoom = 1;
/**
* Controls the speed of zooming. Recommended values are between 1 and 5.
*
* @type number
* @default
*/
this.zoomFactor = 3;
/**
* @type boolean
* @default
*/
this.disableZoom = false;
/**
* Force rectangular and hierarchical trees to use the canvas dimensions
* instead of the number of leaves for proportion at the prerender stage.
*
* @type boolean
*/
this.fillCanvas = false;
/**
* Enable branch scaling.
*
* @type boolean
* @default
*/
this.branchScaling = true;
/**
* The current branch scale.
*
* @type number
*/
this.currentBranchScale = 1;
/**
* The ratio at which branches scale.
*
* @type number
*/
this.branchScalingStep = 1.2;
/**
* Whether a click has been detected.
*
* @type boolean
*/
this.pickedup = false;
/**
* Whether the user is dragging.
*
* @type boolean
*/
this.dragging = false;
/**
* The starting x coordinate of a drag.
*
* @type number
*/
this.startx = null;
/**
* The starting y coordinate of a drag.
*
* @type number
*/
this.starty = null;
/**
* Factor with which to scale the radius of a leaf.
*
* @type number
* @default
*/
this.baseNodeSize = 1;
/**
* Caches the offsetx when a click is detected.
*
* @type number
*/
this.origx = null;
/**
* Caches the offsety when a click is detected.
*
* @type number
*/
this.origy = null;
/**
* The x coordinate from which to begin drawing from.
*
* @type number
*/
this.offsetx = this.canvas.canvas.width / 2;
/**
* The y coordinate from which to begin drawing from.
*
* @type number
*/
this.offsety = this.canvas.canvas.height / 2;
/**
* The colour to apply to a selected branch.
*
* @type string
* @default
*/
this.selectedColour = 'rgba(49,151,245,1)';
/**
* The colour to apply to a hihglighted branch.
*
* @type string
* @default
*/
this.highlightColour = 'rgba(49,151,245,1)';
/**
* The line width of the halo on a highlighted branch.
*
* @type number
* @default
*/
this.highlightWidth = 4;
/**
* Scale factor for the size of the the halo on a highlighted branch.
*
* @type number
* @default
*/
this.highlightSize = 2;
/**
* Global branch colour,
*
* @type string
* @default
*/
this.branchColour = 'rgba(0,0,0,1)';
/**
* Scale factor applied to branch lengths defined in the serialised
* representation of the tree.
*
* @type number
*/
this.branchScalar = 1.0;
/**
* Space to add to bounds when fitting the tree to the canvas.
*
* @type number
* @default
*/
this.padding = 50;
/**
* Space between a leaf and its label.
*
* @type number
* @default
*/
this.labelPadding = 5;
/**
* Enable/disable shift-click multi-selection.
*
* @type boolean
* @default
*/
this.multiSelect = true;
/**
* Flag to change on branch when clicked.
*
* @type string
* @default
*/
this.clickFlag = 'selected';
/**
* Decide if a branch should be affected when clicked.
*
* @type function
*
* @param {Branch} branch
* @param {string} property
* @param {} value
*
* @return boolean
* @default A function returning true.
*/
this.clickFlagPredicate = Predicates.tautology;
/**
* Show labels when hovering over node.
*
* @type boolean
* @default
*/
this.hoverLabel = false;
/**
* @type boolean
* @default
*/
this.internalNodesSelectable = true;
/**
* @type boolean
* @default
*/
this.showLabels = true;
/**
* Global show/hide branch-length labels.
*
* @type boolean
* @default
*/
this.showBranchLengthLabels = false;
/**
* Conditionally display branch-length labels when enabled.
*
* @type function
* @param {Branch} node
* @default
*/
this.branchLengthLabelPredicate = Predicates.tautology;
/**
* @type boolean
* @default
*/
this.showInternalNodeLabels = false;
/**
* Global style for internal labels on branches.
*
* @type object
* @property {string} colour
* @property {number} textSize
* @property {string} font
* @property {string} format - e.g. bold, italic
*/
this.internalLabelStyle = {
colour: this.branchColour,
textSize: this.textSize,
font: this.font,
format: '',
};
this.setTreeType('radial');
/**
* Stores the length of the longest branch on the tree.
*
* @type number
*/
this.maxBranchLength = 0;
/**
* The visible width of the branches.
*
* @type number
* @default
*/
this.lineWidth = 1.0;
/**
* The size of the labels, scaled to the size of the tree on first draw.
*
* @type number
*/
this.textSize = 7;
/**
* The font of the labels.
*
* @type string
*/
this.font = 'sans-serif';
/**
* @type boolean
* @default
*/
this.unselectOnClickAway = true;
/**
* X coordinate of node that is furthest from the root.
*
* @type number
*/
this.farthestNodeFromRootX = 0;
/**
* Y coordinate of node that is furthest from the root.
*
* @type number
*/
this.farthestNodeFromRootY = 0;
/**
* Require the 'shift' key to be depressed to allow dragging
*/
this.shiftKeyDrag = false;
/**
* Maximum length of label for each tree type.
*
* @type Object.<string, number>
*/
this.maxLabelLength = {};
// Override properties from config
Object.assign(this, config);
this.resizeToContainer();
/**
* Event listener cache.
*
* @type object
*/
this.eventListeners = {};
/**
* Default event listeners. Listeners passed in `config.eventListeners` will
* overwrite the listener of the same type.
*/
const eventListeners = Object.assign({
click: { listener: this.clicked.bind(this) },
mousedown: { listener: this.pickup.bind(this) },
mouseup: { listener: this.drop.bind(this) },
mouseout: { listener: this.drop.bind(this) },
mousemove: {
target: this.canvas.canvas,
listener: this.drag.bind(this),
},
mousewheel: {
target: this.canvas.canvas,
listener: this.scroll.bind(this),
},
DOMMouseScroll: {
target: this.canvas.canvas,
listener: this.scroll.bind(this),
},
resize: {
target: window,
listener: () => {
this.resizeToContainer();
this.draw();
},
},
}, config.eventListeners || {});
for (const event of Object.keys(eventListeners)) {
const { listener, target } = eventListeners[event];
this.addListener(event, listener, target);
}
}
/**
* Removes events defined in this.eventListeners. Useful for cleaning up.
*/
removeEventListeners() {
for (const event of Object.keys(this.eventListeners)) {
for (const def of this.eventListeners[event]) {
const { target, listener } = def;
removeEvent(target || this.containerElement, event, listener);
}
}
}
/**
* Set/get if labels are currently aligned.
*
* @type boolean
*/
get alignLabels() {
return this.showLabels && this.labelAlign && this.labelAlignEnabled;
}
set alignLabels(value) {
this.labelAlignEnabled = value;
}
/**
* Collapses branches based on {@link Tree#defaultCollapsed}.
*
* @param {Branch} [node=this.root]
*/
setInitialCollapsedBranches(node = this.root) {
var childIds;
var i;
childIds = node.getChildProperties('id');
if (childIds && childIds.length > this.defaultCollapsed.min &&
childIds.length < this.defaultCollapsed.max) {
node.collapsed = true;
return;
}
for (i = 0; i < node.children.length; i++) {
this.setInitialCollapsedBranches(node.children[i]);
}
}
/**
* @param {MouseEvent} event
* @returns {Branch}
*/
getNodeAtMousePosition(event) {
return this.root.clicked(...translateClick(event, this));
}
/**
* @returns {Branch[]} Selected leaves
*/
getSelectedNodeIds() {
return this.getNodeIdsWithFlag('selected');
}
/**
* @param {string} flag - A boolean property of the branch
* @param {boolean} [value=true]
* @returns {Branch[]}
*/
getNodeIdsWithFlag(flag, value = true) {
return this.leaves.reduce((memo, leaf) => {
if (leaf[flag] === value) {
memo.push(leaf.id);
}
return memo;
}, []);
}
/**
* Event listener for click events.
*
* @param {MouseEvent} e
*/
clicked(e) {
var node;
if (e.button === 0) {
let nodeIds = [];
// if this is triggered by the release after a drag then the click
// shouldn't be triggered.
if (this.dragging) {
this.dragging = false;
return;
}
if (!this.root) return false;
node = this.getNodeAtMousePosition(e);
const isMultiSelectActive = this.multiSelect && (e.metaKey || e.ctrlKey);
if (node && node.interactive) {
if (isMultiSelectActive) {
if (node.leaf) {
node[this.clickFlag] = !node[this.clickFlag];
} else if (this.internalNodesSelectable) {
const someUnflagged = node.getChildProperties(this.clickFlag).some(prop => prop === false);
node.cascadeFlag(this.clickFlag, someUnflagged, this.clickFlagPredicate);
}
nodeIds = this.getNodeIdsWithFlag(this.clickFlag);
this.draw();
} else {
this.root.cascadeFlag(this.clickFlag, false, this.clickFlagPredicate);
if (this.internalNodesSelectable || node.leaf) {
node.cascadeFlag(this.clickFlag, true, this.clickFlagPredicate);
nodeIds = node.getChildProperties('id');
}
this.draw();
}
} else if (this.unselectOnClickAway && !this.dragging && !isMultiSelectActive) {
this.root.cascadeFlag(this.clickFlag, false, this.clickFlagPredicate);
this.draw();
}
if (!this.pickedup) {
this.dragging = false;
}
this.nodesUpdated(nodeIds, this.clickFlag);
}
}
/**
* Handles dragging and hovering.
*
* @param {MouseEvent} event
*/
drag(event) {
// get window ratio
const ratio = getPixelRatio(this.canvas);
if (!this.drawn) return false;
if (this.pickedup) {
const xmove = (event.clientX - this.startx) * ratio;
const ymove = (event.clientY - this.starty) * ratio;
if (Math.abs(xmove) + Math.abs(ymove) > 5) {
this.dragging = true;
this.offsetx = this.origx + xmove;
this.offsety = this.origy + ymove;
this.draw();
}
} else {
// hover
const e = event;
const nd = this.getNodeAtMousePosition(e);
if (nd && nd.interactive && (this.internalNodesSelectable || nd.leaf)) {
this.root.cascadeFlag('hovered', false);
nd.hovered = true;
// For mouseover tooltip to show no. of children on the internal nodes
if (!nd.leaf && !nd.hasCollapsedAncestor()) {
this.tooltip.open(e.clientX, e.clientY, nd);
}
this.containerElement.style.cursor = 'pointer';
} else {
this.tooltip.close();
this.root.cascadeFlag('hovered', false);
if (this.shiftKeyDrag && e.shiftKey) {
setCursorDrag(this.containerElement);
} else {
this.containerElement.style.cursor = 'auto';
}
}
this.draw();
}
}
/**
* Draws the frame.
*
* @param {boolean} forceRedraw - Also run the prerenderer.
*/
draw(forceRedraw) {
this.highlighters.length = 0;
if (this.maxBranchLength === 0) {
this.loadError(new Error('All branches in the tree are identical.'));
return;
}
this.canvas.clearRect(0, 0, this.canvas.canvas.width, this.canvas.canvas.height);
this.canvas.lineCap = 'round';
this.canvas.lineJoin = 'round';
this.canvas.strokeStyle = this.branchColour;
this.canvas.save();
if (!this.drawn || forceRedraw) {
this.prerenderer.run(this);
if (!forceRedraw) {
this.fitInPanel();
}
}
const pixelRatio = getPixelRatio(this.canvas);
this.canvas.lineWidth = this.lineWidth / this.zoom;
this.canvas.translate(this.offsetx * pixelRatio, this.offsety * pixelRatio);
this.canvas.scale(this.zoom, this.zoom);
this.branchRenderer.render(this, this.root);
this.highlighters.forEach(render => render());
this.drawn = true;
this.canvas.restore();
}
/**
* Mousedown event listener
*
* @param {MouseEvent} event
*/
pickup(event) {
if (!this.shiftKeyDrag || event.shiftKey) {
if (!this.drawn) return false;
this.origx = this.offsetx;
this.origy = this.offsety;
if (event.button === 0) {
this.pickedup = true;
setCursorDragging(this.containerElement);
}
this.startx = event.clientX;
this.starty = event.clientY;
}
}
/**
* mouseup event listener.
*/
drop(event) {
if (!this.drawn) return false;
this.pickedup = false;
if (this.shiftKeyDrag && event.shiftKey) {
setCursorDrag(this.containerElement);
} else {
this.containerElement.style.cursor = 'auto';
}
}
/**
* Mousewheel event listener.
*
* @param event
*/
scroll(event) {
if (this.disableZoom || ('wheelDelta' in event && event.wheelDelta === 0)) {
return;
}
event.preventDefault();
this._point.x = event.offsetX;
this._point.y = event.offsetY;
const sign = event.detail < 0 || event.wheelDelta > 0 ? 1 : -1;
if (this.branchScaling && (event.metaKey || event.ctrlKey)) {
this.currentBranchScale *= Math.pow(this.branchScalingStep, sign);
this.setBranchScale(this.currentBranchScale, this._point);
} else {
this.smoothZoom(sign, this._point);
}
}
/**
* @param {RegExp} pattern
* @param {string} [searchProperty=id].
* @return {Branch[]}
*/
findLeaves(pattern, searchProperty = 'id') {
let foundLeaves = [];
for (let leaf of this.leaves) {
if (leaf[searchProperty] && leaf[searchProperty].match(pattern)) {
foundLeaves.push(leaf);
}
}
return foundLeaves;
}
/**
* @param {Branch[]} leaves
* @param {string} property
* @param {} value
*
* @fires Tree#updated
*/
updateLeaves(leaves, property, value) {
for (let leaf of this.leaves) {
leaf[property] = !value;
}
for (let leaf of leaves) {
leaf[property] = value;
}
this.nodesUpdated(leaves.map(_ => _.id), property);
}
/**
* Deselects all branches, implicitly calls {@link Tree#draw}.
*/
clearSelect() {
this.root.cascadeFlag('selected', false);
this.draw();
}
/**
* @returns {string} Base64-encoded data uri of canvas
*/
getPngUrl() {
return this.canvas.canvas.toDataURL();
}
/**
* Loads a serialised representation of a tree, using the first registered
* parser that validates the input unless a format is specified.
*
* @param {string} inputString
* @param {Object} [options] - also passed on to the parser.
* @param {string} [options.format] - specify the parser to use.
* @param {function} [callback] - Called synchronously *after* the first draw.
*
* @fires Tree#error
*
* @see Tree#build
*/
load(inputString, options = {}, callback) {
let buildOptions = options;
let buildCallback = callback;
// allows passing callback as second param
if (typeof options === 'function') {
buildCallback = options;
buildOptions = {};
}
if (buildCallback) {
buildOptions.callback = buildCallback;
}
if (buildOptions.format) {
this.build(inputString, parsers[buildOptions.format], buildOptions);
return;
}
for (const parserName of Object.keys(parsers)) {
const parser = parsers[parserName];
if (inputString.match(parser.fileExtension) ||
inputString.match(parser.validator)) {
this.build(inputString, parser, buildOptions);
return;
}
}
const error = new Error('String not recognised as a file or a parseable format string');
if (buildCallback) {
buildCallback(error);
}
this.loadError(error);
}
/**
* Builds the {@link Tree#originalTree} object.
*/
saveOriginalTree() {
this.originalTree.branches = this.branches;
this.originalTree.leaves = this.leaves;
this.originalTree.root = this.root;
this.originalTree.branchLengths = {};
this.originalTree.parents = {};
}
/**
* Clears the branches and leaves of the instance.
*/
clearState() {
this.root = false;
this.leaves = [];
this.branches = {};
this.drawn = false;
}
/**
* Build {@link Tree#branches} and {@link Tree#leaves} properties.
*/
extractNestedBranches() {
this.branches = {};
this.leaves = [];
this.storeNode(this.root);
this.root.extractChildren();
}
/**
* High-level API to organising branches and leaves.
*
* @fires Tree#error
*/
saveState() {
this.extractNestedBranches();
this.root.branchLength = 0;
this.maxBranchLength = 0;
this.root.setTotalLength();
if (this.maxBranchLength === 0) {
this.loadError(new Error('All branches in the tree are identical.'));
return;
}
}
/**
* Builds the object model of a tree.
*
* @param {string} formatString
* @param {Parser} parser
* @param {Object} options
*
* @fires Tree#error
* @fires Tree#beforeFirstDraw
* @fires Tree#loadCompleted
*/
build(formatString, parser, options) {
this.originalTree = {};
this.clearState();
Branch.lastId = 0;
const root = new Branch();
root.id = 'root';
this.branches.root = root;
this.setRoot(root);
parser.parse({ formatString, root, options }, (error) => {
if (error) {
if (options.callback) {
options.callback(error);
}
this.loadError(error);
return;
}
this.stringRepresentation = formatString;
this.saveState();
this.setInitialCollapsedBranches();
this.beforeFirstDraw();
this.draw();
this.saveOriginalTree();
if (options.callback) {
options.callback();
}
this.loadCompleted();
});
}
/**
* Draw a subtree.
*
* @param {Branch} node - the new root of the tree.
*
* @fires Tree#subtree
*/
redrawFromBranch(node) {
this.clearState();
this.resetTree();
this.originalTree.branchLengths[node.id] = node.branchLength;
this.originalTree.parents[node.id] = node.parent;
this.root = node;
this.root.parent = false;
this.saveState();
this.draw();
this.subtreeDrawn(node.id);
}
/**
* Reload the serialised version of the tree.
*/
redrawOriginalTree() {
this.load(this.stringRepresentation);
}
/**
* Traverse the tree, generating ids and filing away objects.
*
* @param {Branch} node - starting point.
*/
storeNode(node) {
if (!node.id || node.id === '') {
node.id = Branch.generateId();
}
if (this.branches[node.id]) {
if (node !== this.branches[node.id]) {
if (!node.leaf) {
node.id = Branch.generateId();
} else {
throw new Error('Two nodes on this tree share the id ' + node.id);
}
}
}
this.branches[node.id] = node;
if (node.leaf) {
this.leaves.push(node);
}
}
/**
* @param {number} size
*/
setNodeSize(size) {
this.baseNodeSize = Number(size);
this.draw();
}
/**
* @param {Branch} node
*/
setRoot(node) {
node.tree = this;
this.root = node;
}
/**
* @param {number|string} size
*/
setTextSize(size) {
this.textSize = Number(size);
this.draw();
}
/**
* Sets an appropriate font size for the proportions of the tree.
*
* @param {number} ystep - the space between leaves.
*/
setFontSize(ystep) {
this.textSize = this.calculateFontSize ? this.calculateFontSize(ystep) : Math.min((ystep / 2), 15);
this.canvas.font = this.textSize + 'pt ' + this.font;
}
/**
* @param {string} type - The name of a registered tree type.
* @param {boolean} [quiet] - Do not broadcast.
*
* @fires Tree#typechanged
*/
setTreeType(type, quiet) {
if (!(type in treeTypes)) {
return fireEvent(this.containerElement, 'error', { error: new Error(`"${type}" is not a known tree-type.`) });
}
let oldType = this.treeType;
this.treeType = type;
this.type = treeTypes[type];
this.branchRenderer = treeTypes[type].branchRenderer;
this.prerenderer = treeTypes[type].prerenderer;
this.labelAlign = treeTypes[type].labelAlign;
this.calculateFontSize = treeTypes[type].calculateFontSize;
if (this.drawn) {
this.drawn = false;
this.draw();
}
if (!quiet) {
this.treeTypeChanged(oldType, type);
}
}
/**
* Resizes the canvas element.
*
* @param {number} width
* @param {number} height
*/
setSize(width, height) {
this.canvas.canvas.width = width;
this.canvas.canvas.height = height;
if (this.navigator) {
this.navigator.resize();
}
this.adjustForPixelRatio();
}
/**
* Scale the size of the canvas element to the pixel ratio
*/
adjustForPixelRatio() {
var ratio = getPixelRatio(this.canvas);
this.canvas.canvas.style.height = this.canvas.canvas.height + 'px';
this.canvas.canvas.style.width = this.canvas.canvas.width + 'px';
if (ratio > 1) {
this.canvas.canvas.width *= ratio;
this.canvas.canvas.height *= ratio;
}
}
/**
* @returns {{ x: number, y: number }} point w/ x and y coordinates
*/
getCentrePoint() {
const pixelRatio = getPixelRatio(this.canvas);
return {
x: (this.canvas.canvas.width / 2) / pixelRatio,
y: (this.canvas.canvas.height / 2) / pixelRatio,
};
}
/**
* Zoom to a specific level over a specific point.
*
* @param {number} zoom
* @param {{ x: number, y: number }} [point=Tree#getCentrePoint]
*/
setZoom(zoom, { x, y } = this.getCentrePoint()) {
if (zoom > 0) {
const oldZoom = this.zoom;
this.zoom = zoom;
this.offsetx = this.calculateZoomedOffset(this.offsetx, x, oldZoom, zoom);
this.offsety = this.calculateZoomedOffset(this.offsety, y, oldZoom, zoom);
this.draw();
}
}
/**
* Zoom in or out from the current zoom level towards a point.
*
* @param {number} steps - positive to zoom in, negative to zoom out.
* @param {{ x: number, y: number }} point
*/
smoothZoom(steps, point) {
this.setZoom(
Math.pow(10,
(Math.log(this.zoom) / Math.log(10)) + steps * this.zoomFactor * 0.01
), point
);
}
/**
* Magic to enable zooming to a point.
*
* @author Khalil Abudahab
* @param {number} offset
* @param {number} coord
* @param {number} oldZoom
* @param {number} newZoom
*/
calculateZoomedOffset(offset, coord, oldZoom, newZoom) {
return -1 * ((((-1 * offset) + coord) / oldZoom * newZoom) - coord);
}
/**
* Scale branches horizontally
*
* @param {number} scale
* @param {Object} point
*/
setBranchScale(scale = 1, point = { x: this.canvas.canvas.width / 2, y: this.canvas.canvas.height / 2 }) {
const treeType = treeTypes[this.treeType];
if (!treeType.branchScalingAxis || scale < 0) {
return;
}
const previoudBranchLength = this.branchScalar;
this.branchScalar = this.initialBranchScalar * scale;
const scaleRatio = this.branchScalar / previoudBranchLength;
const offset = this[`offset${treeType.branchScalingAxis}`];
const oldPosition = point[treeType.branchScalingAxis];
const newPosition = (point[treeType.branchScalingAxis] - offset) * scaleRatio + offset;
this[`offset${treeType.branchScalingAxis}`] += (oldPosition - newPosition);
this.draw();
}
/**
* @method
*/
toggleLabels() {
this.showLabels = !this.showLabels;
this.draw();
}
/**
* @method
*/
setMaxLabelLength() {
var dimensions;
if (this.maxLabelLength[this.treeType] === undefined) {
this.maxLabelLength[this.treeType] = 0;
}
for (let i = 0; i < this.leaves.length; i++) {
dimensions = this.canvas.measureText(this.leaves[i].id);
// finding the maximum label length
if (dimensions.width > this.maxLabelLength[this.treeType]) {
this.maxLabelLength[this.treeType] = dimensions.width;
}
}
}
/**
* @event Tree#loading
*/
loadStarted() {
fireEvent(this.containerElement, 'loading');
}
/**
* @event Tree#beforeFirstDraw
*/
beforeFirstDraw() {
fireEvent(this.containerElement, 'beforeFirstDraw');
}
/**
* @event Tree#loaded
*/
loadCompleted() {
fireEvent(this.containerElement, 'loaded');
}
/**
* @event Tree#error
* @property {Error} error
*/
loadError(error) {
fireEvent(this.containerElement, 'error', { error });
}
/**
* @event Tree#subtree
* @property {Branch} node
*/
subtreeDrawn(node) {
fireEvent(this.containerElement, 'subtree', { node });
}
/**
* @event Tree#updated
* @property {string[]} nodeIds
* @property {string} property
* @property {boolean} append
*/
nodesUpdated(nodeIds, property, append = false) {
fireEvent(this.containerElement, 'updated', { nodeIds, property, append });
}
/**
* @event Tree#typechanged
* @property {string} oldType
* @property {string} newType
*/
treeTypeChanged(oldType, newType) {
fireEvent(this.containerElement, 'typechanged', { oldType, newType });
}
/**
* @param {string}
* @param {function}
*/
addListener(event, listener, target) {
if (!this.eventListeners[event]) this.eventListeners[event] = [];
this.eventListeners[event].push({ listener, target });
addEvent(target || this.containerElement, event, listener);
}
/**
* @param {string}
* @param {function}
*/
removeListener(event, listener, target) {
removeEvent(target || this.containerElement, event, listener);
}
/**
* @param {Array.<Branch>} [leaves=this.leaves]
*
* @returns {Array.<Array.<number>>} bounds - Minimum x and y coordinates in
* the first array, maximum x and y coordinates in the second.
*
* @example const [ [ minx, miny ], [ maxx, maxy ] ] = tree.getBounds()
*/
getBounds(leaves = this.leaves) {
// this.leaves assumes bounds of whole tree, start from root
const initialBounds = leaves === this.leaves ? this.root : leaves[0];
let minx = initialBounds.startx;
let maxx = initialBounds.startx;
let miny = initialBounds.starty;
let maxy = initialBounds.starty;
for (const leaf of leaves) {
const bounds = leaf.getBounds();
minx = Math.min(minx, bounds.minx);
maxx = Math.max(maxx, bounds.maxx);
miny = Math.min(miny, bounds.miny);
maxy = Math.max(maxy, bounds.maxy);
}
return [ [ minx, miny ], [ maxx, maxy ] ];
}
/**
* Zoom to the provided leaves.
*
* @param {Array.<Branch>}
*/
fitInPanel(leaves) {
this.zoom = 1; // calculates consistent bounds
const bounds = this.getBounds(leaves);
const canvasSize = [
this.canvas.canvas.width - this.padding * 2,
this.canvas.canvas.height - this.padding * 2,
];
const treeSize = [
bounds[1][0] - bounds[0][0],
bounds[1][1] - bounds[0][1],
];
const pixelRatio = getPixelRatio(this.canvas);
const xZoomRatio = canvasSize[0] / treeSize[0];
const yZoomRatio = canvasSize[1] / treeSize[1];
this.zoom = Math.min(xZoomRatio, yZoomRatio);
this.offsetx = (-1 * bounds[0][0]) * this.zoom;
this.offsety = (-1 * bounds[0][1]) * this.zoom;
if (xZoomRatio > yZoomRatio) {
this.offsetx += this.padding +
(canvasSize[0] - (treeSize[0] * this.zoom)) / 2;
this.offsety += this.padding;
} else {
this.offsetx += this.padding;
this.offsety += this.padding +
(canvasSize[1] - (treeSize[1] * this.zoom)) / 2;
}
this.offsetx = this.offsetx / pixelRatio;
this.offsety = this.offsety / pixelRatio;
}
/**
* Reapply data in {@link Tree#originalTree}.
*/
resetTree() {
if (!this.originalTree.branches) return;
this.branches = this.originalTree.branches;
for (const n of Object.keys(this.originalTree.branchLengths)) {
this.branches[n].branchLength = this.originalTree.branchLengths[n];
this.branches[n].parent = this.originalTree.parents[n];
}
this.leaves = this.originalTree.leaves;
this.root = this.originalTree.root;
}
/**
* @param {Branch}
*/
rotateBranch(branch) {
this.branches[branch.id].rotate();
}
/**
* @returns {string} Newick representation of current object model.
*/
exportNwk() {
var nwk = this.root.getNwk();
return nwk.substr(0, nwk.lastIndexOf(')') + 1) + ';';
}
/**
* Resize canvas element to container.
*/
resizeToContainer() {
this.setSize(this.containerElement.offsetWidth, this.containerElement.offsetHeight);
}
/**
* Removes tracked event listeners and provides a hook for plugins to clean up
* after themselves.
*/
cleanup() {
this.removeEventListeners();
}
}
/**
* @memberof Tree
* @method
* @see Tree#addListener
*/
Tree.prototype.on = Tree.prototype.addListener;
/**
* @memberof Tree
* @method
* @see Tree#removeListener
*/
Tree.prototype.off = Tree.prototype.removeListener;
export default Tree;