com.hfg.bio.phylogeny.NewickTree Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of com_hfg Show documentation
Show all versions of com_hfg Show documentation
com.hfg xml, html, svg, and bioinformatics utility library
package com.hfg.bio.phylogeny;
import java.awt.font.FontRenderContext;
import java.awt.geom.AffineTransform;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.io.InputStream;
import java.io.IOException;
import java.io.ByteArrayInputStream;
import java.io.OutputStream;
import java.util.*;
import java.util.List;
import java.awt.*;
import java.awt.image.BufferedImage;
import com.hfg.css.CSS;
import com.hfg.css.CSSDeclaration;
import com.hfg.css.CSSProperty;
import com.hfg.css.CSSRule;
import com.hfg.exception.ProgrammingException;
import com.hfg.graphics.TextUtil;
import com.hfg.graphics.units.GfxSize;
import com.hfg.graphics.units.GfxUnits;
import com.hfg.graphics.units.Pixels;
import com.hfg.graphics.units.Points;
import com.hfg.html.Span;
import com.hfg.html.Pre;
import com.hfg.html.HTMLTag;
import com.hfg.html.StyleTag;
import com.hfg.html.attribute.HTMLColor;
import com.hfg.math.SimpleSampleStats;
import com.hfg.svg.path.SvgPathEllipticalArcCmd;
import com.hfg.svg.path.SvgPathMoveToCmd;
import com.hfg.util.StringBuilderPlus;
import com.hfg.util.StringUtil;
import com.hfg.util.collection.CollectionUtil;
import com.hfg.graphics.ColorUtil;
import com.hfg.util.collection.DataColumn;
import com.hfg.util.collection.DataTable;
import com.hfg.image.ImageIO_Util;
import com.hfg.util.io.StreamUtil;
import com.hfg.xml.XMLName;
import com.hfg.xml.XMLNamespace;
import com.hfg.xml.XMLNode;
import com.hfg.svg.*;
import com.hfg.network.Edge;
import com.hfg.xml.XMLTag;
//------------------------------------------------------------------------------
/**
Object representation of a Newick format phylogenetic tree.
Does not work with negative distance values.
@author J. Alex Taylor, hairyfatguy.com
*/
//------------------------------------------------------------------------------
// com.hfg XML/HTML Coding Library
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
//
// J. Alex Taylor, President, Founder, CEO, COO, CFO, OOPS hairyfatguy.com
// [email protected]
//------------------------------------------------------------------------------
// http://artedi.ebc.uu.se/course/X3-2004/Phylogeny/Phylogeny-Fundamentals/Phylogeny-Basics.html
//
public class NewickTree
{
public static enum Flag
{
ASSIGN_GROUPS_TO_SINGLETONS
}
/**
The ID used for the branch traversal limit ('branchLimit').
*/
public static final String BRANCH_TRAVERSAL_LIMIT_BASE_ID = "branchLimit";
private static int branchTraversalLimitIdSrc = 1;
private PhyloNode mRootNode;
private Font mFont = Font.decode("Arial-PLAIN-10");
private TreeDisplaySettings mSettings;
private DataTable mMetaDataTable;
private int mImageTopPadding = 50;
private int mImageLeftPadding = 50;
private int mAsciiWidth = sDefaultAsciiWidth;
// Cached
private List mAllNodes;
private List mLeafNodes;
private static int sDefaultAsciiWidth = 75;
private static int sSvgScalePadding = 40;
private static int sSvgTickHeight = 10;
private static int sLeafLabelLeftPadding = 10;
private static FontRenderContext sFRC = new FontRenderContext(new AffineTransform(), true, true);
//##########################################################################
// CONSTRUCTORS
//##########################################################################
//--------------------------------------------------------------------------
public NewickTree()
{
}
//--------------------------------------------------------------------------
public NewickTree(String inTree)
{
parse(new ByteArrayInputStream(inTree.getBytes()));
}
//--------------------------------------------------------------------------
public NewickTree(InputStream inStream)
{
parse(inStream);
}
//##########################################################################
// PUBLIC METHODS
//##########################################################################
//--------------------------------------------------------------------------
public static void setDefaultAsciiWidth(int inValue)
{
sDefaultAsciiWidth = inValue;
}
//--------------------------------------------------------------------------
public NewickTree setAsciiWidth(int inValue)
{
mAsciiWidth = inValue;
return this;
}
//--------------------------------------------------------------------------
// 2 child nodes indicates a rooted tree. 3 indicates an unrooted tree.
public boolean isRooted()
{
return (mRootNode != null
&& mRootNode.getLeafFacingEdges().size() == 2
&& null == mRootNode.getParentNode());
}
//--------------------------------------------------------------------------
public PhyloNode getRootNode()
{
return mRootNode;
}
//--------------------------------------------------------------------------
public void setRootNode(PhyloNode inValue)
{
mRootNode = inValue;
inValue.makeRoot();
}
//--------------------------------------------------------------------------
public List getLeafNodes()
{
if (null == mLeafNodes)
{
List leaves = new ArrayList<>();
recursivelyRakeLeaves(leaves, mRootNode);
mLeafNodes = leaves;
}
return mLeafNodes;
}
//--------------------------------------------------------------------------
public List getAllNodes()
{
if (null == mAllNodes)
{
List nodes = new ArrayList<>();
recursivelyAddNodes(nodes, mRootNode);
mAllNodes = nodes;
}
return mAllNodes;
}
//--------------------------------------------------------------------------
public void orderByNodeCount()
{
for (PhyloNode node : getAllNodes())
{
node.orderEdgesByLeafCount();
}
}
//--------------------------------------------------------------------------
public Float getRootedTreeDistance()
{
return mRootNode.getMaxDistanceToLeaf();
}
//--------------------------------------------------------------------------
public List> getMaxLeaf2LeafEdgeTrail()
{
List> edgeTrail = null;
List leaves = getLeafNodes();
if (leaves.size() > 1)
{
Edge maxEdge = null;
for (int i = 0; i < leaves.size() - 1; i++)
{
PhyloNode leaf1 = leaves.get(i);
for (int j = i + 1; j < leaves.size(); j++)
{
PhyloNode leaf2 = leaves.get(j);
float distance = leaf1.distanceTo(leaf2);
if (null == maxEdge
|| maxEdge.getDistance() < distance)
{
maxEdge = new Edge<>(leaf1, leaf2, distance);
}
}
}
edgeTrail = new ArrayList<>();
Edge edge = maxEdge.getFrom().getParentEdge();
while(edge != null)
{
edgeTrail.add(edge);
edge = edge.getFrom().getParentEdge();
}
List> trailingTrail = new ArrayList<>();
edge = maxEdge.getTo().getParentEdge();
while(edge != null)
{
trailingTrail.add(edge);
edge = edge.getFrom().getParentEdge();
}
for (int i = trailingTrail.size() - 1; i >= 0; i--)
{
edgeTrail.add(trailingTrail.get(i));
}
}
return edgeTrail;
}
//--------------------------------------------------------------------------
public void rootTreeByMidpointMethod()
{
List> maxEdgeTrail = getMaxLeaf2LeafEdgeTrail();
if (maxEdgeTrail != null)
{
float distance = 0.0f;
Edge prevEdge = null;
for (Edge edge : maxEdgeTrail)
{
distance += edge.getDistance() != null ? edge.getDistance() : 0.0f;
if (null == prevEdge
|| prevEdge.getTo() != edge.getFrom())
{
edge.switchDirection();
}
prevEdge = edge;
}
// Find the midpoint
distance /= 2;
for (Edge edge : maxEdgeTrail)
{
if (edge.getDistance() != null
&& edge.getDistance() > distance)
{
// Insert the new root in this edge.
PhyloNode newRoot = new PhyloNode();
newRoot.addEdge(edge.getTo(), distance);
newRoot.addEdge(edge.getFrom(), edge.getDistance() - distance);
// This will remove the edge from BOTH attached nodes.
edge.getTo().removeEdge(edge);
setRootNode(newRoot);
break;
}
else if (edge.getDistance() != null
&& edge.getDistance() == distance)
{
setRootNode(edge.getTo());
}
else
{
distance -= edge.getDistance();
}
}
}
}
//--------------------------------------------------------------------------
public List> groupNodes(float inBranchLengthTraversalLimit, Flag... inFlags)
{
Set flags = new HashSet<>(5);
if (inFlags != null)
{
for (Flag flag : inFlags)
{
flags.add(flag);
}
}
List> groups = new ArrayList<>();
for (PhyloNode leaf : getLeafNodes())
{
Float bestFit = null;
List bestGroup = null;
for (List group : groups)
{
for (PhyloNode node : group)
{
float distance = leaf.distanceTo(node);
if (distance <= inBranchLengthTraversalLimit)
{
if (null == bestFit
|| distance < bestFit)
{
bestFit = distance;
bestGroup = group;
}
}
}
}
if (bestGroup != null)
{
bestGroup.add(leaf);
}
else
{
// Create a new group
List group = new ArrayList<>();
group.add(leaf);
groups.add(group);
}
}
// Remove singletons ?
if (! flags.contains(Flag.ASSIGN_GROUPS_TO_SINGLETONS))
{
for (int i = groups.size() - 1; i >= 0; i--)
{
if (groups.get(i).size() == 1)
{
groups.remove(i);
}
}
}
return groups;
}
//--------------------------------------------------------------------------
/**
Note that this DistanceMatrix may not be equivalent to the DistanceMatrix
originally used to construct the tree.
@return the DistanceMatrix for the tree
*/
public DistanceMatrix toDistanceMatrix()
{
DistanceMatrix matrix = new DistanceMatrix();
for (PhyloNode node1 : getLeafNodes())
{
for (PhyloNode node2 : getLeafNodes())
{
if (node1 != node2)
{
matrix.setDistance(node1.getLabel(), node2.getLabel(), node1.distanceTo(node2));
}
}
}
return matrix;
}
//--------------------------------------------------------------------------
/**
Returns a Newick format representation of the tree.
@return Newick format string representation of the tree
*/
@Override
public String toString()
{
StringBuilder buffer = new StringBuilder();
if (mRootNode != null) buffer.append(mRootNode);
buffer.append(";");
return buffer.toString();
}
//--------------------------------------------------------------------------
/**
Constructs a phylogram in ASCII format.
@return an ASCII representation of the tree
*/
public String toASCII()
{
int numLines = getLeafNodes().size() * 2 - 1;
List lines = new ArrayList<>(numLines);
// Initialize the lines.
for (int i = 0; i < numLines; i++)
{
StringBuilder line = new StringBuilder();
line.append(StringUtil.polyChar(' ', mAsciiWidth + 100));
lines.add(line);
}
int leftPadding = 3;
// Determine the scale.
double scalingFactor = mAsciiWidth / getRootedTreeDistance();
assignLineIndexes();
for (PhyloNode node : getAllNodes())
{
Edge parentEdge = node.getParentEdge();
// if (null == parentEdge || null == parentEdge.getFrom()) continue;
float distance = (parentEdge != null && parentEdge.getDistance() != null ? parentEdge.getDistance() : 0.0f);
int branchStart = leftPadding + (int)((node.getDistanceFromRoot() - distance) * scalingFactor);
int branchEnd = leftPadding + (int) (node.getDistanceFromRoot() * scalingFactor);
int branchLength = branchEnd - branchStart + 1;
// Branch
lines.get(node.getLineIndex()).replace(branchStart, branchEnd + 1, "+" + StringUtil.polyChar('-', branchLength - 1));
if (node.isLeaf())
{
if (StringUtil.isSet(node.getLabel()))
{
lines.get(node.getLineIndex()).replace(branchEnd + 2, branchEnd + 2 + node.getLabel().length(), node.getLabel());
}
}
else
{
for (int lineIndex = node.getVericalLineStartIndex() + 1; lineIndex < node.getVericalLineEndIndex(); lineIndex++)
{
if (branchLength > 1 || lineIndex != node.getLineIndex()) lines.get(lineIndex).replace(branchEnd, branchEnd + 1, "|");
}
}
}
StringBuilder output = new StringBuilder();
for (StringBuilder line : lines)
{
output.append(StringUtil.trimTrailingWhitespace(line));
output.append(System.getProperty("line.separator"));
}
return output.toString();
}
//--------------------------------------------------------------------------
/**
Constructs a phylogram in HTML format.
@return an HTML representation of the tree
*/
public HTMLTag toHTMLTag()
{
int numLines = getLeafNodes().size() * 2 - 1;
List lines = new ArrayList<>(numLines);
// Initialize the lines.
for (int i = 0; i < numLines; i++)
{
StringBuilder line = new StringBuilder();
line.append(StringUtil.polyChar(' ', mAsciiWidth + 100));
lines.add(line);
}
int leftPadding = 3;
// Determine the scale.
double scalingFactor = mAsciiWidth / getRootedTreeDistance();
assignLineIndexes();
for (PhyloNode node : getAllNodesOrderedByDistance())
{
Edge parentEdge = node.getParentEdge();
float distance = (parentEdge != null && parentEdge.getDistance() != null ? parentEdge.getDistance() : 0.0f);
int branchStart = leftPadding + (int)((node.getDistanceFromRoot() - distance) * scalingFactor);
int branchEnd = leftPadding + (int) (node.getDistanceFromRoot() * scalingFactor);
int branchLength = branchEnd - branchStart + 1;
// Branch
Span branchSpan = new Span("+");
if (branchLength > 1) branchSpan.addContent(StringUtil.polyChar('-', branchLength - 2) + (node.isLeaf() ? "-" : "|"));
if (distance != 0.0f)
{
branchSpan.setTitle(distance + "");
}
StringBuilder line = lines.get(node.getLineIndex());
for (int i = branchEnd; i >= branchStart; i--)
{
if (line.charAt(i) != ' ' && line.charAt(i) != '|')
{
branchEnd--;
branchLength--;
branchSpan.setContent(branchSpan.getContent().substring(0, branchSpan.getContent().length() - 1));
}
else
{
break;
}
}
if (branchLength > 0)
{
line.replace(branchStart, branchEnd + 1, branchSpan.toHTML());
}
int tagEnd = branchStart + branchSpan.toHTML().length();
if (node.isLeaf())
{
if (StringUtil.isSet(node.getLabel()))
{
Span nodeSpan = new Span(node.getLabel());
if (node.getColor() != null)
{
nodeSpan.setStyle(CSS.bgColor(node.getColor()) + CSS.color(HTMLColor.getContrastingColor(node.getColor())));
}
line.replace(tagEnd + 2, tagEnd + 2 + node.getLabel().length(), nodeSpan.toHTML());
}
}
else
{
for (int lineIndex = node.getVericalLineStartIndex() + 1; lineIndex < node.getVericalLineEndIndex(); lineIndex++)
{
if (lineIndex != node.getLineIndex()
&& lines.get(lineIndex).charAt(branchEnd) == ' ')
{
lines.get(lineIndex).replace(branchEnd, branchEnd + 1, "|");
}
}
}
}
StringBuilder output = new StringBuilder();
for (StringBuilder line : lines)
{
output.append(line);
output.append(System.getProperty("line.separator"));
}
//TODO: add the scale to the bottom.
Pre pre = new Pre();
pre.addContentWithoutEscaping(output.toString());
return pre;
}
//--------------------------------------------------------------------------
public void setMetaDataForDisplay(DataTable inMetaData)
{
mMetaDataTable = inMetaData;
}
//--------------------------------------------------------------------------
public XMLNode toSVG()
{
return toSVG(new TreeDisplaySettings());
}
//--------------------------------------------------------------------------
public XMLNode toSVG(TreeDisplaySettings inDisplaySettings)
{
XMLNode svg;
switch (inDisplaySettings.getCladogramStyle())
{
case Rectangular:
svg = toRectangularCladogramSVG(inDisplaySettings);
break;
case Circular:
svg = toCircularCladogramSVG(inDisplaySettings);
break;
default:
throw new ProgrammingException(StringUtil.singleQuote(inDisplaySettings.getCladogramStyle()) + " is not a recognized CladogramStyle!");
}
return svg;
}
//--------------------------------------------------------------------------
private XMLNode toRectangularCladogramSVG(TreeDisplaySettings inDisplaySettings)
{
// Determine the scale.
int treeWidth = inDisplaySettings.getTreeWidth();
float treeDistance = getRootedTreeDistance();
double scalingFactor = treeWidth / treeDistance;
// Determine a top-to-bottom node order
assignLineIndexes();
int maxLeafX = 0;
int lineHeight = (int) TextUtil.getStringRect("A", mFont).getHeight();
SVG svg = new SVG();
svg.setFont(mFont);
float maxLabelWidth = getMaxLabelWidth();
SvgGroup treeGroup = svg.addGroup().setClass("tree");
// treeGroup.setAttribute("transform", "scale(1)");
// Add rectangle behind the tree which can be used as a drag handle if panning and zooming
treeGroup.addRect(new Rectangle2D.Float(mImageLeftPadding, mImageTopPadding, treeWidth + sLeafLabelLeftPadding + maxLabelWidth, getLeafNodes().size() * 2 * lineHeight)).setOpacity(0);
for (PhyloNode node : getAllNodes())
{
Edge parentEdge = node.getParentEdge();
float distance = (parentEdge != null && parentEdge.getDistance() != null ? parentEdge.getDistance() : 0.0f);
int branchStart = mImageLeftPadding + (int)((node.getDistanceFromRoot() - distance) * scalingFactor);
int branchEnd = mImageLeftPadding + (int) (node.getDistanceFromRoot() * scalingFactor);
int branchY = mImageTopPadding + (node.getLineIndex() * lineHeight) - (lineHeight / 2);
// Branch
SvgLine branch = treeGroup.addLine(new Point(branchStart, branchY), new Point(branchEnd, branchY));
if (distance != 0.0f)
{
branch.setTitle(distance + "");
}
if (node.isLeaf())
{
SvgNode svgNode = treeGroup.addCircle().setCx(branchEnd).setCy(mImageTopPadding + (node.getLineIndex() * lineHeight) - (lineHeight / 2)).setR(2).setId(node.getId()).addClass("leaf");
PhyloNode parentNode = node.getParentNode();
if (parentNode != null)
{
svgNode.setAttribute("parentId", parentNode.getId());
// svgNode.setAttribute("dist", node.getParentEdge().getDistance());
}
if (StringUtil.isSet(node.getLabel()))
{
int labelX;
if (inDisplaySettings.getAlignLeaftNodeLabels())
{
// Add a guide line to better match the label to the node
int guideStartX = branchEnd + sLeafLabelLeftPadding;
if (guideStartX > mImageLeftPadding + treeWidth)
{
guideStartX = mImageLeftPadding + treeWidth;
}
int guideEndX = mImageLeftPadding + treeWidth;
treeGroup.addLine(new Point2D.Double(guideStartX, branchY), new Point2D.Double(guideEndX, branchY)).addStyle("stroke:#" + ColorUtil.colorToHex(HTMLColor.LIGHT_GRAY));
labelX = guideEndX + sLeafLabelLeftPadding;
}
else
{
labelX = branchEnd + sLeafLabelLeftPadding;
}
Rectangle2D textBoundBox = mFont.getStringBounds(node.getLabel(), 0, node.getLabel().length(), sFRC);
// Create a colored rectangle to go behind the text.
SvgRect rect = treeGroup.addRect(new Rectangle(new Point(labelX - 2,
mImageTopPadding + node.getLineIndex() * lineHeight - (int) textBoundBox.getHeight() - 2),
new Dimension((int)textBoundBox.getMaxX() + 4, (int)textBoundBox.getHeight() + 4)));
// rect.setStyle("stroke-width: 0.5; stroke: #000000; fill:#" + ColorUtil.colorToHex(node.getColor()) + ";");
rect.addStyle("border:none; fill:" + (node.getColor() != null ? "#" + ColorUtil.colorToHex(node.getColor()) : "none") + ";");
rect.setId(node.getId() + "_labelBox");
// Create the label text
SvgText label = treeGroup.addText(node.getLabel(), mFont, new Point(labelX, mImageTopPadding + node.getLineIndex() * lineHeight - 2))
.setId(node.getId() + "_label");
int labelRightX = branchEnd + sLeafLabelLeftPadding + (int) textBoundBox.getMaxX();
if (labelRightX > maxLeafX)
{
maxLeafX = labelRightX;
}
if (node.getColor() != null)
{
label.setFill(new HTMLColor(node.getColor()).getContrastingColor());
}
}
}
else
{
// Add the vertical connector line
treeGroup.addLine(new Point(branchEnd, mImageTopPadding + (node.getVericalLineStartIndex() * lineHeight) - (lineHeight / 2)),
new Point(branchEnd, mImageTopPadding + (node.getVericalLineEndIndex() * lineHeight) - (lineHeight / 2)));
}
}
if (inDisplaySettings.getShowScale()
|| inDisplaySettings.getEnableDynamicGrouping())
{
// Add the scale to the bottom.
svg.addSubtag(generateSvgScale(inDisplaySettings, svg, new Point2D.Double(mImageLeftPadding, treeGroup.getBoundsBox().getHeight() + mImageTopPadding + sSvgScalePadding)));
}
if (mMetaDataTable != null)
{
SvgGroup dataGroup = treeGroup.addGroup();
for (DataColumn col : mMetaDataTable.getDataColumns())
{
int leftX = maxLeafX + 40;
int y = mImageTopPadding - (2 * lineHeight);
Rectangle colTitleTextBoundBox = TextUtil.getStringRect(col.getTitle(), mFont);
if (leftX + colTitleTextBoundBox.getMaxX() > maxLeafX)
{
maxLeafX = leftX + (int) colTitleTextBoundBox.getMaxX();
}
SvgText label = dataGroup.addText(col.getTitle(), mFont, new Point(leftX, y));
// Underline the column header
dataGroup.addLine(new Point(leftX, y + 2),
new Point(leftX + (int) colTitleTextBoundBox.getMaxX(), y + 2));
for (PhyloNode leafNode : getLeafNodes())
{
Object dataValue = mMetaDataTable.get(leafNode.getId(), col);
if (dataValue != null)
{
String dataValueString = dataValue.toString();
Rectangle textBoundBox = TextUtil.getStringRect(dataValueString, mFont);
if (leftX + textBoundBox.getMaxX() > maxLeafX)
{
maxLeafX = leftX + (int) textBoundBox.getMaxX();
}
int x = leftX;
if (dataValue instanceof Number)
{
// Right justify
x += (int) (colTitleTextBoundBox.getMaxX() - textBoundBox.getMaxX()) - 4;
}
y = mImageTopPadding + leafNode.getLineIndex() * lineHeight;
// Display the data value
dataGroup.addText(dataValueString, mFont, new Point(x, y));
}
}
}
}
return svg;
}
//--------------------------------------------------------------------------
private StyleTag generateScopedStyleTag()
{
StyleTag styleTag = new StyleTag().setScoped();
styleTag.addRule(new CSSRule(".tree path, .tree line { stroke-width:0.2 !important; stroke-linecap:round }"));
styleTag.addRule(new CSSRule(".tree .guide { stroke:#" + ColorUtil.colorToHex(HTMLColor.LIGHT_GRAY) + " }"));
return styleTag;
}
//--------------------------------------------------------------------------
private XMLNode toCircularCladogramSVG(TreeDisplaySettings inDisplaySettings)
{
List leafNodes = getLeafNodes();
double degreesPerNode = 360f/(leafNodes.size() + 1);
int minLabelHeight = 2;
float minRadius = calculateMinRadius(degreesPerNode, minLabelHeight);
// Determine the scale.
float radius = inDisplaySettings.getTreeWidth() / 2;
if (radius < minRadius)
{
radius = minRadius;
}
float treeDistance = getRootedTreeDistance();
if (treeDistance <= 0)
{
treeDistance = 0.0001f;
}
double scalingFactor = radius / treeDistance;
// Scale down the label font if necessary
float maxLabelSize = calculateMaxLabelSize(degreesPerNode, radius);
if (new Points(mFont.getSize()).to(GfxUnits.pixels) > maxLabelSize)
{
mFont = new Font(mFont.getName(), mFont.getStyle(), (int) new Pixels(maxLabelSize).to(GfxUnits.points));
}
float nodeCircleRadius = 0.5f;
double labelBoxOffsetRadians = -0.0005f;
// double labelBoxOffsetRadians = -maxLabelSize * 0.01;
float maxLabelWidth = getMaxLabelWidth();
Point2D centerPoint = new Point2D.Float(mImageLeftPadding + maxLabelWidth + radius,
mImageTopPadding + maxLabelWidth + radius);
// Assign angles to the leaves
int leafCount = 0;
for (PhyloNode leafNode : leafNodes)
{
leafCount++;
double angle = 180 + (-degreesPerNode * leafCount);
double angleInRadians = Math.toRadians(angle);
leafNode.setAngle(angleInRadians);
}
SVG svg = new SVG();
svg.setFont(mFont);
svg.addSubtag(generateScopedStyleTag());
SvgGroup treeGroup = svg.addGroup().setClass("tree");
// treeGroup.setAttribute("transform", "scale(1)");
// Add circle behind the tree which can be used as a drag handle if panning and zooming
treeGroup.addCircle().setCenter(centerPoint).setR((int) (maxLabelWidth + radius)).setOpacity(0);
for (PhyloNode node : getAllNodes())
{
Edge parentEdge = node.getParentEdge();
float distance = (parentEdge != null && parentEdge.getDistance() != null ? parentEdge.getDistance() : 0.0f);
if (node.isLeaf())
{
double angleInRadians = node.getAngle();
double startX = centerPoint.getX() + ((node.getDistanceFromRoot() - distance) * scalingFactor) * Math.sin(angleInRadians);
double startY = centerPoint.getY() + ((node.getDistanceFromRoot() - distance) * scalingFactor) * Math.cos(angleInRadians);
double endX = centerPoint.getX() + (node.getDistanceFromRoot() * scalingFactor) * Math.sin(angleInRadians);
double endY = centerPoint.getY() + (node.getDistanceFromRoot() * scalingFactor) * Math.cos(angleInRadians);
treeGroup.addLine(new Point2D.Double(startX, startY), new Point2D.Double(endX, endY));
SvgNode svgNode = treeGroup.addCircle().setCx(endX).setCy(endY).setR(nodeCircleRadius).setId(node.getId()).addClass("leaf");
PhyloNode parentNode = node.getParentNode();
if (parentNode != null)
{
svgNode.setAttribute("parentId", parentNode.getId());
// svgNode.setAttribute("dist", node.getParentEdge().getDistance());
}
float labelRadius;
if (inDisplaySettings.getAlignLeaftNodeLabels())
{
// Add a guide line to better match the label to the node
float guideRadius = (float) (node.getDistanceFromRoot() * scalingFactor) + 10;
if (guideRadius < radius)
{
double guideStartX = centerPoint.getX() + guideRadius * Math.sin(angleInRadians);
double guideStartY = centerPoint.getY() + guideRadius * Math.cos(angleInRadians);
double guideEndX = centerPoint.getX() + radius * Math.sin(angleInRadians);
double guideEndY = centerPoint.getY() + radius * Math.cos(angleInRadians);
treeGroup.addLine(new Point2D.Double(guideStartX, guideStartY), new Point2D.Double(guideEndX, guideEndY)).setClass("guide");
}
labelRadius = radius + 2;
}
else
{
labelRadius = (float) (node.getDistanceFromRoot() * scalingFactor) + 2f;
}
if (StringUtil.isSet(node.getLabel()))
{
Rectangle2D textBoundBox = mFont.getStringBounds(node.getLabel(), 0, node.getLabel().length(), sFRC);
double labelX = centerPoint.getX() + labelRadius * Math.sin(angleInRadians);
double labelY = centerPoint.getY() + labelRadius * Math.cos(angleInRadians);
String labelTransform = "rotate(" + SVG.formatCoordinate(360 - Math.toDegrees(angleInRadians) + 90) + " " + SVG.formatCoordinate(labelX) + " " + SVG.formatCoordinate(labelY) + ")";
// Create a colored rectangle to go behind the text.
double labelBoxX = centerPoint.getX() + labelRadius * Math.sin(angleInRadians + labelBoxOffsetRadians);
double labelBoxY = centerPoint.getY() + labelRadius * Math.cos(angleInRadians + labelBoxOffsetRadians);
SvgRect rect = treeGroup.addRect(new Rectangle2D.Double(labelBoxX, labelBoxY - textBoundBox.getHeight(), textBoundBox.getWidth(), textBoundBox.getHeight()));
// We always need to create the label box even if the label has no color
// because we need to support dynamic grouping via javascript.
rect.addStyle("stroke-width:" + SVG.formatCoordinate(0.15 * textBoundBox.getHeight()) + "px;"); // Add a 15% border to better frame the text
rect.addStyle("stroke:" + (node.getColor() != null ? "#" + ColorUtil.colorToHex(node.getColor()) : "none") + ";");
rect.addStyle("fill:" + (node.getColor() != null ? "#" + ColorUtil.colorToHex(node.getColor()) : "none") + ";");
rect.setTransform("rotate(" + SVG.formatCoordinate(360 - Math.toDegrees(angleInRadians + labelBoxOffsetRadians) + 90) + " " + SVG.formatCoordinate(labelBoxX) + " " + SVG.formatCoordinate(labelBoxY) + ")");
rect.setId(node.getId() + "_labelBox");
// Create the label text
SvgText label = treeGroup.addText(node.getLabel(), mFont, new Point2D.Float((float) labelX, (float) labelY))
.setTransform(labelTransform)
.setId(node.getId() + "_label");
if (node.getColor() != null)
{
label.setFill(new HTMLColor(node.getColor()).getContrastingColor());
}
}
}
else
{
if (null == node.getAngle())
{
recursivelyAssignAngles(node);
}
List childNodes = node.getChildNodes();
double startAngleInRadians = childNodes.get(0).getAngle();
double endAngleInRadians = childNodes.get(childNodes.size() - 1).getAngle();
double deltaAngleInRadians = childNodes.get(0).getAngle() - childNodes.get(childNodes.size() - 1).getAngle();
double startX = centerPoint.getX() + (node.getDistanceFromRoot() * scalingFactor) * Math.sin(startAngleInRadians);
double startY = centerPoint.getY() + (node.getDistanceFromRoot() * scalingFactor) * Math.cos(startAngleInRadians);
double endX = centerPoint.getX() + (node.getDistanceFromRoot() * scalingFactor) * Math.sin(endAngleInRadians);
double endY = centerPoint.getY() + (node.getDistanceFromRoot() * scalingFactor) * Math.cos(endAngleInRadians);
// Like Noah, we need to build an arc
SvgPath path = treeGroup.addPath().setFill(null).setStroke(Color.BLACK);//TODO .setStrokeWidth(1);
path.addPathCommand(new SvgPathMoveToCmd().addPoint(new Point2D.Double(startX, startY)));
List arcNumbers = new ArrayList<>(7);
arcNumbers.add((float) (node.getDistanceFromRoot() * scalingFactor)); // rx
arcNumbers.add((float) (node.getDistanceFromRoot() * scalingFactor)); // ry
arcNumbers.add(0f); // x-rotation
arcNumbers.add(deltaAngleInRadians > Math.PI ? 1f : 0f); // large-arc-sweep-flag
arcNumbers.add(1f); // sweep-flag
arcNumbers.add((float)endX);
arcNumbers.add((float)endY);
path.addPathCommand(new SvgPathEllipticalArcCmd().setRawNumbers(arcNumbers));
// Now add the connector back to the previous node
double angleInRadians = (startAngleInRadians + endAngleInRadians) / 2f;
node.setAngle(angleInRadians);
startX = centerPoint.getX() + ((node.getDistanceFromRoot() - distance) * scalingFactor) * Math.sin(angleInRadians);
startY = centerPoint.getX() + ((node.getDistanceFromRoot() - distance) * scalingFactor) * Math.cos(angleInRadians);
endX = centerPoint.getX() + (node.getDistanceFromRoot() * scalingFactor) * Math.sin(angleInRadians);
endY = centerPoint.getX() + (node.getDistanceFromRoot() * scalingFactor) * Math.cos(angleInRadians);
treeGroup.addLine(new Point2D.Double(startX, startY), new Point2D.Double(endX, endY));
SvgNode svgNode = treeGroup.addCircle().setCx(endX).setCy(endY).setR(nodeCircleRadius).setId(node.getId());
PhyloNode parentNode = node.getParentNode();
if (parentNode != null)
{
svgNode.setAttribute("parentId", parentNode.getId());
// svgNode.setAttribute("dist", node.getParentEdge().getDistance());
}
}
}
if (inDisplaySettings.getShowScale())
{
// Add the scale to the bottom.
Rectangle treeBox = treeGroup.getBoundsBox();
Point2D topLeft = new Point2D.Double(centerPoint.getX() - radius,
treeBox.getHeight() + treeBox.getY() + mImageTopPadding + sSvgScalePadding);
svg.addSubtag(generateSvgScale(inDisplaySettings, svg, topLeft));
}
Rectangle contentRect = svg.getContentBoundsBox();
if (contentRect.getMinX() < 0
|| contentRect.getMinY() < 0)
{
int xOffset = (int) (contentRect.getX() < 0 ? -contentRect.getX() : 0);
int yOffset = (int) (contentRect.getY() < 0 ? -contentRect.getY() : 0);
contentRect.translate(xOffset, yOffset);
svg.setViewBox(contentRect);
svg.setTransform("translate(" + xOffset + "," + yOffset + ")");
}
return svg;
}
//--------------------------------------------------------------------------
// Calculates the maximum label size (height) given the number of labels on the circle
// and the circle's radius.
private float calculateMaxLabelSize(double inDegreesPerNode, float inLabelRadius)
{
double angle1 = inDegreesPerNode;
double angleInRadians1 = Math.toRadians(angle1);
double angle2 = inDegreesPerNode * 2;
double angleInRadians2 = Math.toRadians(angle2);
double pt1_x = inLabelRadius * Math.sin(angleInRadians1);
double pt1_y = inLabelRadius * Math.cos(angleInRadians1);
double pt2_x = inLabelRadius * Math.sin(angleInRadians2);
double pt2_y = inLabelRadius * Math.cos(angleInRadians2);
double distance = Math.sqrt((pt1_x -= pt2_x) * pt1_x + (pt1_y -= pt2_y) * pt1_y);
return 0.8f * (float) distance;
}
//--------------------------------------------------------------------------
// Calculates the minimum circle radius to achieve a minimum label size (height)
// given the number of labels on the circle expressed as degrees per node.
private float calculateMinRadius(double inDegreesPerNode, float inMinLabelHeight)
{
double angleInRadians = Math.toRadians(inDegreesPerNode);
return (float) ((inMinLabelHeight / 2) / Math.sin(angleInRadians/2));
}
//--------------------------------------------------------------------------
private float getMaxLabelWidth()
{
float maxLabelWidth = 0f;
for (PhyloNode leafNode : getLeafNodes())
{
if (StringUtil.isSet(leafNode.getLabel()))
{
Rectangle textBoundBox = TextUtil.getStringRect(leafNode.getLabel(), mFont);
if (textBoundBox.getWidth() > maxLabelWidth)
{
maxLabelWidth = (float) textBoundBox.getWidth();
}
}
}
return maxLabelWidth;
}
//--------------------------------------------------------------------------
private void recursivelyAssignAngles(PhyloNode inStartingNode)
{
SimpleSampleStats stats = new SimpleSampleStats();
for (PhyloNode childNode : inStartingNode.getChildNodes())
{
if (null == childNode.getAngle())
{
recursivelyAssignAngles(childNode);
}
stats.add(childNode.getAngle());
}
inStartingNode.setAngle(stats.getMean());
}
//--------------------------------------------------------------------------
private void attachDistanceMatrixAsMetadata(SVG inSVG, TreeDisplaySettings inDisplaySettings)
{
XMLNamespace hfgNamespace = XMLNamespace.getNamespace("hfg", "http://hairyfatguy.com");
inSVG.addXMLNamespaceDeclaration(hfgNamespace);
// Build a distance matrix
XMLTag distMatrixTag = new XMLTag(new XMLName("distancematrix", hfgNamespace));
List leafNodes = getLeafNodes();
for (int i = 0; i < leafNodes.size(); i++)
{
PhyloNode leaf1 = leafNodes.get(i);
StringBuilderPlus buffer = new StringBuilderPlus().setDelimiter(" ");
for (int j = 0; j <= i; j++)
{
PhyloNode leaf2 = leafNodes.get(j);
buffer.delimitedAppend(leaf1.distanceTo(leaf2));
}
buffer.insert(0, leaf1.getId() + ":");
buffer.appendln();
distMatrixTag.addContent(buffer);
}
inSVG.getMetadata().addSubtag(distMatrixTag);
XMLTag settingsTag = new XMLTag(new XMLName("settings", hfgNamespace));
XMLTag settingTag = new XMLTag(new XMLName("setting", hfgNamespace));
settingTag.setAttribute("name", "assignGroupsToSingletons");
settingTag.setAttribute("value", inDisplaySettings.getAssignGroupsToSingletons());
settingsTag.addSubtag(settingTag);
if (inDisplaySettings.getBLTL_OnChangeCallback() != null)
{
settingTag = new XMLTag(new XMLName("setting", hfgNamespace));
settingTag.setAttribute("name", "bltlOnChangeCallback");
settingTag.setAttribute("value", inDisplaySettings.getBLTL_OnChangeCallback());
settingsTag.addSubtag(settingTag);
}
if (inDisplaySettings.getBLTL_SelectionCompleteCallback() != null)
{
settingTag = new XMLTag(new XMLName("setting", hfgNamespace));
settingTag.setAttribute("name", "bltlSelectionCompleteCallback");
settingTag.setAttribute("value", inDisplaySettings.getBLTL_SelectionCompleteCallback());
settingsTag.addSubtag(settingTag);
}
inSVG.getMetadata().addSubtag(settingsTag);
}
//--------------------------------------------------------------------------
public static String generateSvgJavascript()
throws IOException
{
String rsrcName = "rsrc/cladogram_bltl.js";
InputStream rsrcStream = NewickTree.class.getResourceAsStream(rsrcName);
if (null == rsrcStream)
{
throw new IOException("The javascript rsrc " + StringUtil.singleQuote(rsrcName) + " couldn't be found!");
}
String js = StreamUtil.inputStreamToString(rsrcStream);
return js;
}
//--------------------------------------------------------------------------
public void toJPG(TreeDisplaySettings inDisplaySettings, OutputStream inStream)
throws IOException
{
ImageIO_Util.writeBufferedImageAsJpeg(getBufferedImage(inDisplaySettings), inStream);
}
//--------------------------------------------------------------------------
public BufferedImage getBufferedImage(TreeDisplaySettings inDisplaySettings)
{
SVG svg = (SVG) toSVG(inDisplaySettings);
// Add padding to the right
svg.setWidth(svg.getWidth() + mImageLeftPadding);
Frame frame = new Frame();
frame.addNotify();
BufferedImage bufferedImage = new BufferedImage(svg.getWidth(),
svg.getHeight(),
BufferedImage.TYPE_INT_RGB);
Graphics2D g2 = (Graphics2D) bufferedImage.getGraphics();
svg.draw(g2);
return bufferedImage;
}
//--------------------------------------------------------------------------
public BufferedImage getBufferedImageWithMaxWidth(TreeDisplaySettings inDisplaySettings, GfxSize inMaxWidth)
{
SVG svg = (SVG) toSVG(inDisplaySettings);
// Add padding to the right
svg.setWidth(svg.getWidth() + mImageLeftPadding);
if (svg.getWidth() > inMaxWidth.to(GfxUnits.pixels))
{
float scalingFactor = inMaxWidth.to(GfxUnits.pixels) / svg.getWidth();
svg.scale(scalingFactor);
}
Frame frame = new Frame();
frame.addNotify();
BufferedImage bufferedImage = new BufferedImage(svg.getWidth(),
svg.getHeight(),
BufferedImage.TYPE_INT_RGB);
Graphics2D g2 = (Graphics2D) bufferedImage.getGraphics();
svg.draw(g2);
return bufferedImage;
}
//--------------------------------------------------------------------------
public List getAllNodesOrderedByDistance()
{
List nodes = new ArrayList<>(getAllNodes());
Collections.sort(nodes, new NodeDistanceComparator());
return nodes;
}
//##########################################################################
// PRIVATE METHODS
//##########################################################################
//--------------------------------------------------------------------------
private String getFormatString(float inValue)
{
String formatString = "%";
if (inValue > 1000)
{
formatString += ".2e";
}
else if (inValue < 0.001)
{
formatString += ".2e";
}
else
{
formatString += ".2f";
}
return formatString;
}
//--------------------------------------------------------------------------
private void parse(InputStream inStream)
{
try
{
StringBuilder buffer = new StringBuilder();
PhyloNode currentNode = null;
int prevChar = 0;
int theChar;
while ((theChar = inStream.read()) != -1
&& theChar != ';')
{
if (Character.isWhitespace(theChar)) continue;
if (prevChar == ','
|| prevChar == ')')
{
currentNode = currentNode.getParentNode();
}
if (theChar == '(')
{
if (null == currentNode)
{
currentNode = new PhyloNode();
mRootNode = currentNode;
}
else
{
PhyloNode newNode = new PhyloNode();
currentNode.addEdge(newNode, null);
currentNode = newNode;
}
}
else if (prevChar == ')')
{
// currentNode = currentNode.getParentNode();
// Read the label (if present).
buffer.setLength(0);
if (theChar != ':'
&& theChar != ',')
{
do
{
buffer.append((char) theChar);
}
while ((theChar = inStream.read()) != -1
&& theChar != ':'
&& theChar != ','
&& theChar != ';');
if (StringUtil.isSet(buffer.toString()))
{
currentNode.setId(buffer.toString());
currentNode.setLabel(buffer.toString());
}
}
}
else if (prevChar == '('
|| prevChar == ',')
{
PhyloNode newNode = new PhyloNode();
currentNode.addEdge(newNode, null);
currentNode = newNode;
// Read the label (if present).
buffer.setLength(0);
do
{
// buffer.append(theChar == '_' ? ' ' : (char) theChar);
buffer.append((char) theChar);
}
while ((theChar = inStream.read()) != -1
&& theChar != ':'
&& theChar != ','
&& theChar != ';');
if (StringUtil.isSet(buffer.toString()))
{
currentNode.setId(buffer.toString());
currentNode.setLabel(buffer.toString());
}
}
else if (prevChar == ':')
{
// Read the distance value (branch length)
buffer.setLength(0);
do
{
buffer.append((char) theChar);
}
while ((theChar = inStream.read()) != -1
&& theChar != ')'
&& theChar != ','
&& theChar != ';');
if (StringUtil.isSet(buffer.toString()))
{
Edge parentEdge = currentNode.getParentEdge();
if (null == parentEdge)
{
Edge edge = new Edge(null, currentNode, Float.parseFloat(buffer.toString()));
currentNode.addEdge(edge);
}
else
{
parentEdge.setDistance(Float.parseFloat(buffer.toString()));
}
}
}
prevChar = theChar;
}
// Assign node ids
int nodeCount = 0;
for (PhyloNode node : getAllNodes())
{
node.setId(++nodeCount);
}
}
catch (IOException e)
{
throw new RuntimeException(e);
}
}
//--------------------------------------------------------------------------
private void recursivelyRakeLeaves(List inLeaves, PhyloNode inNode)
{
if (inNode.isLeaf())
{
inLeaves.add(inNode);
}
else
{
List> leafFacingEdges = inNode.getLeafFacingEdges();
for (Edge edge : leafFacingEdges)
{
recursivelyRakeLeaves(inLeaves, edge.getTo());
}
}
}
//--------------------------------------------------------------------------
private void recursivelyAddNodes(List inNodes, PhyloNode inNode)
{
inNodes.add(inNode);
List> leafFacingEdges = inNode.getLeafFacingEdges();
if (CollectionUtil.hasValues(leafFacingEdges))
{
for (Edge edge : leafFacingEdges)
{
recursivelyAddNodes(inNodes, edge.getTo());
}
}
}
//--------------------------------------------------------------------------
private void assignLineIndexes()
{
int leafIndex = 0;
List leafNodes = getLeafNodes();
for (PhyloNode leaf : leafNodes)
{
leaf.setLineIndex(leafIndex++ * 2);
}
for (PhyloNode leaf : leafNodes)
{
PhyloNode node = leaf.getParentNode();
if (node != null)
{
do
{
if (node.getLineIndex() != null) break;
// Find the line index range of the child nodes
List> leafFacingEdges = node.getLeafFacingEdges();
Integer lowerBound = leafFacingEdges.get(0).getTo().getLineIndex();
Integer upperBound = leafFacingEdges.get(leafFacingEdges.size() - 1).getTo().getLineIndex();
// Can't assign yet if the bounds aren't set.
if (null == lowerBound || null == upperBound) break;
node.setLineIndex(lowerBound + (upperBound - lowerBound) / 2);
node.setVerticalLineStartIndex(lowerBound);
node.setVerticalLineEndIndex(upperBound);
node = node.getParentNode();
}
while (node != null);
}
}
}
//--------------------------------------------------------------------------
private SvgGroup generateSvgScale(TreeDisplaySettings inDisplaySettings, SVG inSVG, Point2D inStartPoint)
{
int treeWidth = inDisplaySettings.getTreeWidth();
float treeDistance = getRootedTreeDistance();
double scalingFactor = treeWidth / treeDistance;
SvgGroup scalegroup = new SvgGroup();
// Horizontal axis
SvgLine axis = scalegroup.addLine(inStartPoint, new Point2D.Double(inStartPoint.getX() + treeWidth, inStartPoint.getY()));
// Tick marks
int numTicks = 4;
for (int i = 0; i <= numTicks; i++)
{
scalegroup.addLine(new Point2D.Double(inStartPoint.getX() + (i * treeWidth/numTicks), inStartPoint.getY()),
new Point2D.Double(inStartPoint.getX() + (i * treeWidth/numTicks), (int) inStartPoint.getY() + sSvgTickHeight));
}
// Labels
String formatString = getFormatString(treeDistance);
for (int i = 0; i <= numTicks; i++)
{
String label = String.format(formatString, treeDistance * i/numTicks);
Rectangle2D textBoundBox = mFont.getStringBounds(label, 0, label.length(), sFRC);
scalegroup.addText(label, mFont, new Point2D.Double(inStartPoint.getX() + (i * treeWidth/numTicks) - (textBoundBox.getWidth()/2),
inStartPoint.getY() + sSvgTickHeight + textBoundBox.getHeight()));
}
if (inDisplaySettings.getEnableDynamicGrouping())
{
axis.addStyle("stroke-width:4px; stroke-linecap:round");
if (null == inDisplaySettings.getBranchLengthTraversalLimit())
{
inDisplaySettings.setBranchLengthTraversalLimit(0.25f * getRootedTreeDistance());
}
else
{
inDisplaySettings.boundsCheckBranchLengthTraversalLimit(this);
}
attachDistanceMatrixAsMetadata(inSVG, inDisplaySettings);
float sliderPosition = (float) (inDisplaySettings.getBranchLengthTraversalLimit() * scalingFactor) / 2f;
// Handle for adjusting the BLTL
SvgCircle handle = scalegroup.addCircle().setCx((int) (inStartPoint.getX() + sliderPosition)).setCy((int) inStartPoint.getY()).setR(8).setId("bltl_slider_handle");
handle.addStyle(" fill:#ffffff");
handle.addStyle(" stroke:#000000");
handle.addStyle(" stroke-width:4px");
handle.addStyle(" cursor:grab");
handle.addStyle(" cursor:-moz-grab");
handle.addStyle(" cursor:-webkit-grab");
handle.setAttribute("x", (int) inStartPoint.getX() + sliderPosition);
handle.setAttribute("min_x", (int) inStartPoint.getX());
handle.setAttribute("max_x", (int) inStartPoint.getX() + treeWidth);
handle.setAttribute("scaling_factor", scalingFactor);
handle.setOnMouseOver("branchTraversalLimitHandleOnMouseOver(this);");
handle.setOnMouseOut("branchTraversalLimitHandleOnMouseOut(this);");
handle.setOnMouseDown("branchTraversalLimitHandleDragStart(evt, this)");
}
return scalegroup;
}
//##########################################################################
// INNER CLASSES
//##########################################################################
private class NodeDistanceComparator implements Comparator
{
public int compare(PhyloNode node1, PhyloNode node2)
{
return - node1.getDistanceFromRoot().compareTo(node2.getDistanceFromRoot());
}
}
}