From 3968bd42023b551aa76271b20ebfe19666762fe4 Mon Sep 17 00:00:00 2001 From: Marcus Fedarko Date: Tue, 22 Sep 2020 14:43:49 -0700 Subject: [PATCH] Some SVG export fixes; add PNG export; other misc. improvements (#384) * MNT: SVG export code tweaks - Declare variables with "var" - exportSvg() -> exportSVG() - exportSVG_legend() -> exportSVGLegend() - other minor things * MNT/ENH: tidy export code; mv legends to viz right Progress on #303 * STY: rm redundant declaration * MNT/DOC: document legend svg; consistent var names * MNT: don't draw a black circle @ root anymore * MNT: remove apparently redundant dom arg in func * init vb text method skeleton * MNT/TST: Add "legendType" attr to Legend; test will be useful when representing arbitrary legends in svg export * MNT: Store main legend in Empress, not SidePanel * TST: add legend-main to test index.html should unbreak tests * STY: pret * MNT/TST: store title easily in legend obj & test again, will help svg export * MNT: Delegate legend SVG exporting to Legend Still gotta, like, test the bulk of this tho. also code needs formatting * MNT/STY: make legend export work; prettify Also changed exported legend appearance a bit (no rounded rects, bg is white, color squares have a black border, etc) * STY: pret * MNT: refactor svg code;attempt to fix viewbox junk legend still isn't properly accounted for in the viewbox; gotta fix. code is a lot prettier tho * STY: jshint thing * MNT: use consistent legend font in svg; fix width? the width is a sledgehammer solution; need to make it exactly correct. idk whats wrong * BUG: fix (most of the) viewbox pbms with svg Now legends are fit in to the viewbox, and things seem mostly ok. Turns out the problem was using tree width (and hgt, but the problems i was seeing were just from width b/c legend wasn't fitting in on the x-axis) alongside "max X" stuff. Just storing things in units of "max X", etc. and THEN doing the -> width conversion after computing the SVG of the tree and legends fixed things. Remaining problems: - Push down the legend a bit; it looks like there is like a pixel or two at the top of it (in the border) that isn't shown. - Remove extra blank space on the right edge of the legend. Likely due to (2 * 4) (aka 2 * NODE_RADIUS) stuff being overzealous in how I'm using it. - Remove extra blank space on bottom edge of the legend when it is taller than the tree svg (e.g. for moving pictures f.m. coloring on level 7 taxonomy). Likely for same reason as above. - The tree is still flipped upside down. wat. Also, I need to document the heck out of the new SVG functions (in both empress.js and legend.js), and add tests preferably. Then it's PR time? * BUG???: Negate y-coords in SVG export: fix #334 I have NO IDEA why this works. Maybe ... is Empress itself flipping the y-coordinates in the first place, and the SVG export was right all along??? argh. * STY: pret * ENH: UI mockup of PNG export/export inclusion opts Inclusion options discussed with @ElDeveloper this morning. For an initial PR i'll probably comment out a lot of this stuff (e.g. the barplots) but it should be adaptable as we continue to support more exporting stuff * ENH: more ui mockup stuff for exporting * MNT: remove unused read() func + skbio imports we still need to include skbio as a dep because _plot needs to reference skbio.OrdinationResult or something (and also some of the python tests load trees as skbio TreeNodes before converting them to bp), but none of the main Empress python code touches TreeNode now at least * ENH: by dflt don't draw circles; draw forall nodes even for nodes without a specified name. Closes #349 and closes #348. TODO: improve selection menu interface for no-name nodes * ENH: Polish UI for unnamed nodes - Add fancy description in selected node menu, rather than saying "Name: null" - Add note to BPTree.getNodesWithName() about dangers of having null as a key in BPTree._nameToNodes. Would be nice to test this case eventuallly ... * STY: prettify * DOC: clarify getNodeCoords docs * TST: unbreak getNodeCoords test for #348 * ENH: add early PNG export (close #330) Also comment out some unused stuff from the export panel for now * ENH: keep sel node shape as circle; node rad attrs See comments -- basically, when node circles were turned off, the selected node shape turned into a square. now it's always a circle which is nice ux-wise * ENH: p major refactor of svg stuff -Splitting up tree and legend exporting, per convo with @ElDeveloper -Make tree export (SVG and PNG) work nicely (SVG still needs barplot and collapsed clade support; that'll come soon!) -Add docs Just left is legend exporting, which shouldn't be too bad. knock on wood. That'll also include barplot legends, I think! * STY: prettify, and abstract rgb code to new func * ENH: polish up legend svg export + clean code fixed bug where all legends getting plomped on top of each other b/c maxy wasn't updated properly. still has slight bug where there's extra empty space below the bottom legend. not sure where originator is but it seems to be exactly 2*unit pixels * MNT: clean up leg exporting code a tiny bit * MNT: for now, don't export the same legend 3 times (was doing that to test multi legend junk but for now it's ok) * BUG: Fix extra v-space bug (kinda) in leg export * STY/DOC: prettify and upd8 comment re vspace thing * MNT: whoops only include one of the tree legend * DOC: document a lotta stuff * DOC: add note re negating * DOC: fix wording * DOC: document exporting fixes * DOC: word unnamed node message better * DOC: close a paren and add context to null thing * DOC: tidy up some comments * ENH: center legend titles in export * ENH: make legend colors snug, like in-app legends So, part of my confusion earlier was that rects (i.e. color squares) have their x and y describe their top left, but BY DEFAULT text tags in SVG have their x and y describe their bottom left. And you gotta use dominant-baseline to change that. Not sure what sadist approved that but ok * BUG/DOC: fix legend svg width;add docs;abstr code * STY: prettify~~~ * DOC: document funky legend stuff tbh i'm still kinda lost on why this works as well as it does but OH WELL :D * MNT: use SVG styles to cut down on redundant code * STY: prettify the styling i warned you about styling bro / i told you dog * MNT: Create context in Legend.exportSVG() removes some work from empress.js, and lets us adjust context based on legend font (shouldn't be dynamic but just in case :O) * DOC: font family -> font style more accurate description * MNT: Move exporting code to export-util.js * STY: preeettify * TST: unbreak JS tests (point to export-util.js) Also rm'd an extra log statement * DOC: Point Legend.exportSVG docs to ExportUtil due to code reorganization in PR * BUG: fix Emperor selection callback Noticed that double-clicking on a category didn't work with the biplot generated for this PR. turns out that that's b/c I forgot to update the way the legend is cleared in that file. should be good now * PERF: Clear node circle buffer with toggling Pair-programmed with @kwcantrell * BUG/MNT: rename drawer radii to diams & upd8 svg --- README.md | 4 +- empress/support_files/js/animator.js | 16 +- empress/support_files/js/bp-tree.js | 11 +- empress/support_files/js/drawer.js | 30 +- empress/support_files/js/empress.js | 364 +++++------------- empress/support_files/js/export-util.js | 256 ++++++++++++ empress/support_files/js/legend.js | 224 ++++++++++- .../support_files/js/node-click-callback.js | 2 +- empress/support_files/js/select-node-menu.js | 25 +- .../support_files/js/selection-callback.js | 2 +- .../support_files/js/side-panel-handler.js | 61 +-- .../templates/empress-template.html | 12 +- .../support_files/templates/side-panel.html | 21 +- empress/tools.py | 12 - tests/index.html | 7 + tests/test-circular-layout-computation.js | 1 - tests/test-empress.js | 9 +- tests/test-legend.js | 66 +++- 18 files changed, 758 insertions(+), 365 deletions(-) create mode 100644 empress/support_files/js/export-util.js diff --git a/README.md b/README.md index 32c69992f..73df372cf 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,9 @@ This was a brief introduction to some of the barplot functionality available in ## Exporting Plots -Once you are done customizing your tree, you can export the tree as an .SVG file by going to the *Export* section in the main menu and clicking on `Export tree as SVG`. Currently, certain elements of the display (e.g. barplots) are not exported; we're working on making this functionality more comprehensive. +Once you are done customizing your tree, you can export the tree as an SVG or PNG file by going to the *Export* section in the main menu and clicking on `Export tree as SVG` or `Export tree as PNG`. You can also export the legend used for tree coloring, if the tree has been colored, using the `Export legend as SVG` button. + +Currently, certain elements of the display (e.g. barplots, collapsed clades) are not included in the SVG export; we're working on making this functionality more comprehensive. Also, note that the SVG export does not change as you zoom / pan the tree, while the PNG export will change as you zoom / pan the tree. ## Empire plots! Side-by-side integration of tree and PCoA plots diff --git a/empress/support_files/js/animator.js b/empress/support_files/js/animator.js index b0600e7c6..a045b0a9f 100644 --- a/empress/support_files/js/animator.js +++ b/empress/support_files/js/animator.js @@ -7,27 +7,17 @@ define(["Colorer", "util"], function (Colorer, util) { * * @param{Empress} empress The core class. Entry point for all metadata and * tree operations. - * @param{Legend} legend Display on the left side of screen. The legend will - * show the current time frame and the color assigned the - * trajectories. * * @returns{Animator} * @constructs Animator */ - function Animator(empress, legend) { + function Animator(empress) { /** * @type {Empress} * The Empress state machine */ this.empress = empress; - /** - * @type {Legend} - * Used to display current time frame and the color assigned the - * trajectories. - */ - this.legend = legend; - /** * @type {Object} * Stores the legend info for each timeframe @@ -227,7 +217,7 @@ define(["Colorer", "util"], function (Colorer, util) { } // draw new legend - this.legend.addCategoricalKey(name, keyInfo); + this.empress.updateLegendCategorical(name, keyInfo); // draw tree this.empress.resetTree(); @@ -329,7 +319,7 @@ define(["Colorer", "util"], function (Colorer, util) { */ Animator.prototype.stopAnimation = function () { this.__resetParams(); - this.legend.clear(); + this.empress.clearLegend(); this.empress.resetTree(); this.empress.drawTree(); }; diff --git a/empress/support_files/js/bp-tree.js b/empress/support_files/js/bp-tree.js index e00d53a79..0705d3b44 100644 --- a/empress/support_files/js/bp-tree.js +++ b/empress/support_files/js/bp-tree.js @@ -708,7 +708,8 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { return numTips; }; - /** True if name is in the names array for the tree + /** + * True if name is in the names array for the tree * * @param {String} name The name to search for. * @return {Boolean} If the name is in the tree. @@ -721,6 +722,14 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { * Returns all nodes with a given name. Once a name has been searched for, * the returned object is cached in this._nameToNodes. * + * NOTE: Care should be taken to make sure that this._nameToNodes is not + * populated with a literal null at any point, since Objects in JS store + * all keys as Strings (so having a literal null in this._nameToNodes [due + * to unnamed nodes] will cause this null to get confused with node(s) + * literally named "null" in the Newick file). I don't think this is + * currently possible in the code, but we should probably add tests that + * verify this. + * * @param {String} name The name of node(s) * @return {Array} An array of postorder positions of nodes with a given * name. If no nodes have the specified name, this will be diff --git a/empress/support_files/js/drawer.js b/empress/support_files/js/drawer.js index 3b2fae456..d77ce64b8 100644 --- a/empress/support_files/js/drawer.js +++ b/empress/support_files/js/drawer.js @@ -62,7 +62,15 @@ define(["glMatrix", "Camera"], function (gl, Camera) { // the dimension of the canvas this.dim = null; - this.showTreeNodes = true; + // Diameters of normal node circles and selected node circles. Note + // that, since these are constant values, they take up the same screen + // space regardless of zoom level. It would be possible to adjust these + // as the user zooms; would help unclutter the tree when it's zoomed + // out. + this.NODE_CIRCLE_DIAMETER = 4.0; + this.SELECTED_NODE_CIRCLE_DIAMETER = 9.0; + + this.showTreeNodes = false; } /** @@ -317,18 +325,17 @@ define(["glMatrix", "Camera"], function (gl, Camera) { }; /** - * Display the tree nodes. - * Note: Currently Empress will only display the nodes that had an assigned - * name in the newick string. + * Determine whether or not to draw circles for each node in the tree. * * Note: this will only take effect after draw() is called. * - * @param{Boolean} showTreeNodes If true the empress with display the tree - * nodes. + * @param{Boolean} showTreeNodes If true then Empress will draw node + * circles. */ Drawer.prototype.setTreeNodeVisibility = function (showTreeNodes) { this.showTreeNodes = showTreeNodes; }; + /** * Draws tree and other metadata */ @@ -351,16 +358,21 @@ define(["glMatrix", "Camera"], function (gl, Camera) { // set the mvp attribute c.uniformMatrix4fv(s.mvpMat, false, mvp); + // This seems to determine whether or not points are drawn as squares + // or as circles (1 = circle, 0 = square). We set it to 1 so that node + // circles and the selected node are both drawn as circles, and then + // set it to 0 afterwards. + + c.uniform1i(s.isSingle, 1); // draw tree node circles, if requested if (this.showTreeNodes) { - c.uniform1i(s.isSingle, 1); - c.uniform1f(s.pointSize, 4.0); + c.uniform1f(s.pointSize, this.NODE_CIRCLE_DIAMETER); this.bindBuffer(s.nodeVertBuff); c.drawArrays(c.POINTS, 0, this.nodeSize); } // draw selected node - c.uniform1f(s.pointSize, 9.0); + c.uniform1f(s.pointSize, this.SELECTED_NODE_CIRCLE_DIAMETER); this.bindBuffer(s.selectedNodeBuff); c.drawArrays(gl.POINTS, 0, this.selectedNodeSize); diff --git a/empress/support_files/js/empress.js b/empress/support_files/js/empress.js index 46443f262..392d1d2d6 100644 --- a/empress/support_files/js/empress.js +++ b/empress/support_files/js/empress.js @@ -6,9 +6,11 @@ define([ "VectorOps", "CanvasEvents", "BarplotPanel", + "Legend", "util", "chroma", "LayoutsUtil", + "ExportUtil", ], function ( _, Camera, @@ -17,9 +19,11 @@ define([ VectorOps, CanvasEvents, BarplotPanel, + Legend, util, chroma, - LayoutsUtil + LayoutsUtil, + ExportUtil ) { /** * @class EmpressTree @@ -140,6 +144,13 @@ define([ this._treeData[i].splice(this._tdToInd.visible, 0, true); } + /** + * @type {Legend} + * Legend describing the way the tree is colored. + * @private + */ + this._legend = new Legend(document.getElementById("legend-main")); + /** * @type {BiomTable} * BIOM table: includes feature presence information and sample-level @@ -235,6 +246,12 @@ define([ */ this._currentLineWidth = 0; + /** + * @type{Bool} + * Whether or not to draw node circles. + */ + this.drawNodeCircles = false; + /** * @type{Bool} * Whether the camera is focused on a selected node. @@ -392,6 +409,8 @@ define([ this._drawer.initialize(); this._events.setMouseEvents(); var nodeNames = this._tree.getAllNames(); + // Don't include nodes with the name null (i.e. nodes without a + // specified name in the Newick file) in the auto-complete. nodeNames = nodeNames.filter((n) => n !== null); nodeNames.sort(); this._events.autocomplete(nodeNames); @@ -437,262 +456,63 @@ define([ */ Empress.prototype.drawTree = function () { this._drawer.loadTreeBuff(this.getCoords()); - this._drawer.loadNodeBuff(this.getNodeCoords()); + if (this.drawNodeCircles) { + this._drawer.loadNodeBuff(this.getNodeCoords()); + } else { + // Clear the node circle buffer to save some memory / space + this._drawer.loadNodeBuff([]); + } this._drawer.loadCladeBuff(this._collapsedCladeBuffer); this._drawer.draw(); }; /** - * Creates an SVG string to export the current drawing + * Exports a SVG image of the tree. + * + * @return {String} svg */ - Empress.prototype.exportSvg = function () { - // TODO: use the same value as the actual WebGL drawing engine, but - // right now this value is hard coded on line 327 of drawer.js - NODE_RADIUS = 4; - - minX = 0; - maxX = 0; - minY = 0; - maxY = 0; - svg = ""; - - // create a line from x1,y1 to x2,y2 for every two consecutive coordinates - // 5 array elements encode one coordinate: - // i=x, i+1=y, i+2=red, i+3=green, i+4=blue - svg += "\n"; - coords = this.getCoords(); - for ( - i = 0; - i + 2 * this._drawer.VERTEX_SIZE <= coords.length; - i += 2 * this._drawer.VERTEX_SIZE - ) { - // "normal" lines have a default color, - // all other lines have a user defined thickness - // All lines are defined using the information from the child node. - // So, if coords[i+2] == DEFAULT_COLOR then coords[i+2+5] will - // also be equal to DEFAULT_COLOR. Thus, we can save checking three - // array elements here. - linewidth = 1 + this._currentLineWidth; - if ( - coords[i + 2] == this.DEFAULT_COLOR[0] && - coords[i + 3] == this.DEFAULT_COLOR[1] && - coords[i + 4] == this.DEFAULT_COLOR[2] - ) { - linewidth = 1; - } - svg += - '\n'; - - // obtain viewport from tree coordinates - minX = Math.min( - minX, - coords[i], - coords[i + this._drawer.VERTEX_SIZE] - ); - maxX = Math.max( - maxX, - coords[i], - coords[i + this._drawer.VERTEX_SIZE] - ); + Empress.prototype.exportTreeSVG = function () { + return ExportUtil.exportTreeSVG(this, this._drawer); + }; - minY = Math.min( - minY, - coords[i + 1], - coords[i + 1 + this._drawer.VERTEX_SIZE] - ); - maxY = Math.max( - maxY, - coords[i + 1], - coords[i + 1 + this._drawer.VERTEX_SIZE] - ); + /** + * Exports a SVG image of the active legends. + * + * Currently this just includes the legend used for tree coloring, but + * eventually this'll be expanded to include all the barplot legends as + * well. + * + * @return {String} svg + */ + Empress.prototype.exportLegendSVG = function () { + var legends = []; + if (!_.isNull(this._legend.legendType)) { + legends.push(this._legend); } - - // create a circle for each node - if (this._drawer.showTreeNodes) { - svg += "\n"; - coords = this.getNodeCoords(); - for ( - i = 0; - i + this._drawer.VERTEX_SIZE <= coords.length; - i += this._drawer.VERTEX_SIZE - ) { - // getNodeCoords array seem to be larger than necessary and - // elements are initialized with 0. Thus, nodes at (0, 0) will - // be skipped (root will always be positioned at 0,0 and drawn - // below) This is a known issue and will be resolved with #142 - if (coords[i] == 0 && coords[i + 1] == 0) { - continue; - } - svg += - '\n'; - } + // TODO: get legends from barplot panel, which should in turn get them + // from each of its barplot layers. For now, we just export the tree + // legend, since we don't support exporting barplots quite yet (soon!) + if (legends.length === 0) { + util.toastMsg("No active legends to export.", 5000); + return null; + } else { + return ExportUtil.exportLegendSVG(legends); } - - // add one black circle to indicate the root - // Not sure if this speacial treatment for root is necessary once #142 - // is merged. - svg += "\n"; - svg += - '\n'; - - return [ - svg, - 'viewBox="' + - (minX - NODE_RADIUS) + - " " + - (minY - NODE_RADIUS) + - " " + - (maxX - minX + 2 * NODE_RADIUS) + - " " + - (maxY - minY + 2 * NODE_RADIUS) + - '"', - ]; }; /** - * Creates an SVG string to export legends + * Exports a PNG image of the canvas. + * + * This works a bit differently from the SVG exporting functions -- instead + * of returning a string with the SVG, the specified callback will be + * called with the Blob representation of the PNG. See + * ExportUtil.exportTreePNG() for details. + * + * @param {Function} callback Function that will be called with a Blob + * representing the exported PNG image. */ - Empress.prototype.exportSVG_legend = function (dom) { - // top left position of legends, multiple legends are placed below - // each other. - top_left_x = 0; - top_left_y = 0; - unit = 30; // all distances are based on this variable, thus "zooming" - // can be realised by just increasing this single value - factor_lineheight = 1.8; // distance between two text lines as a - // multiplication factor of unit - svg = ""; // the svg string to be generated - - // used as a rough estimate about the consumed width by text strings - var myCanvas = document.createElement("canvas"); - var context = myCanvas.getContext("2d"); - context.font = "bold " + unit + "pt verdana"; - - // the document can have up to three legends, of which at most one shall - // be visible at any given timepoint. This might change and thus this - // method can draw multiple legends - row = 1; // count the number of used rows - for (let legend of dom.getElementsByClassName("legend")) { - max_line_width = 0; - title = legend.getElementsByClassName("legend-title"); - svg_legend = ""; - if (title.length > 0) { - titlelabel = title.item(0).innerHTML; - max_line_width = Math.max( - max_line_width, - context.measureText(titlelabel).width - ); - svg_legend += - '' + - titlelabel + - "\n"; - row++; - for (let item of legend.getElementsByClassName( - "gradient-bar" - )) { - color = item - .getElementsByClassName("category-color") - .item(0) - .getAttribute("style") - .split(":")[1] - .split(";")[0]; - itemlabel = item - .getElementsByClassName("gradient-label") - .item(0) - .getAttribute("title"); - max_line_width = Math.max( - max_line_width, - context.measureText(itemlabel).width - ); - - // a rect left of the label to indicate the used color - svg_legend += - '\n'; - // the key label - svg_legend += - '' + - itemlabel + - "\n"; - row++; - } - // draw a rect behind, i.e. lower z-order, the legend title and - // colored keys to visually group the legend. Also acutally put - // these elements into a group for easier manual editing - // rect shall have a certain padding, its height must exceed - //number of used text rows and width must be larger than longest - // key text and/or legend title - svg += - '\n\n' + - svg_legend + - "\n"; - row += 2; // one blank row between two legends - } - } - - return svg; + Empress.prototype.exportTreePNG = function (callback) { + ExportUtil.exportTreePNG(this, this._canvas, callback); }; /** @@ -718,10 +538,11 @@ define([ }; /** - * Retrieves the node coordinate info - * format of node coordinate info: [x, y, red, green, blue, ...] + * Retrieves the node coordinate info (for drawing node circles). * - * @return {Array} + * @return {Array} Node coordinate info, formatted like + * [x, y, red, green, blue, ...] for every node circle to + * be drawn. */ Empress.prototype.getNodeCoords = function () { var tree = this._tree; @@ -731,13 +552,14 @@ define([ if (!this.getNodeInfo(node, "visible")) { continue; } - if (this.getNodeInfo(node, "name") !== null) { - coords.push( - this.getX(node), - this.getY(node), - ...this.getNodeInfo(node, "color") - ); - } + // In the past, we only drew circles for nodes with an assigned + // name (i.e. where the name of a node was not null). Now, we + // just draw circles for all nodes. + coords.push( + this.getX(node), + this.getY(node), + ...this.getNodeInfo(node, "color") + ); } return new Float32Array(coords); }; @@ -1966,6 +1788,8 @@ define([ // color tree this._colorTree(obs, cm); + this.updateLegendCategorical(cat, keyInfo); + return keyInfo; }; @@ -2087,6 +1911,8 @@ define([ // color tree this._colorTree(obs, cm); + this.updateLegendCategorical(cat, keyInfo); + return keyInfo; }; @@ -2216,6 +2042,29 @@ define([ this._group = new Array(this._tree.size + 1).fill(-1); }; + /** + * Clears the legend. + */ + Empress.prototype.clearLegend = function () { + this._legend.clear(); + }; + + /** + * Updates the legend based on a categorical color key. + * + * This is set up as a public method so that the Animator can update the + * legend on its own (without having to reference this._legend from outside + * of Empress). + * + * @param {String} name Text to show in the legend title. + * @param {Object} keyInfo Color key information. Maps unique values (e.g. + * in sample or feature metadata) to their assigned + * color, expressed in hex format. + */ + Empress.prototype.updateLegendCategorical = function (name, keyInfo) { + this._legend.addCategoricalKey(name, keyInfo); + }; + /** * Returns a list of sample categories * @@ -2338,13 +2187,12 @@ define([ /** * Display the tree nodes. - * Note: Currently Empress will only display the nodes that had an assigned - * name in the newick string. * - * @param{Boolean} showTreeNodes If true then empress will display the tree - * nodes. + * @param{Boolean} showTreeNodes If true, then Empress will draw circles at + * each node's position. */ Empress.prototype.setTreeNodeVisibility = function (showTreeNodes) { + this.drawNodeCircles = showTreeNodes; this._drawer.setTreeNodeVisibility(showTreeNodes); this.drawTree(); }; diff --git a/empress/support_files/js/export-util.js b/empress/support_files/js/export-util.js new file mode 100644 index 000000000..f07a00975 --- /dev/null +++ b/empress/support_files/js/export-util.js @@ -0,0 +1,256 @@ +define(["underscore", "chroma"], function (_, chroma) { + /** + * Given a SVG string and min/max x/y positions, creates an exportable SVG. + * + * Mostly this just creates a viewBox attribute and wraps everything in an + * . + * + * @param {String} svg An SVG string to wrap within a . + * @param {Number} minX + * @param {Number} minY + * @param {Number} maxX + * @param {Number} maxY + * + * @return {String} A "finished" SVG string that can be saved to a file. + */ + function _finalizeSVG(svg, minX, minY, maxX, maxY) { + var width = maxX - minX; + var height = maxY - minY; + var viewBox = + 'viewBox="' + minX + " " + minY + " " + width + " " + height + '"'; + return ( + '\n" + + svg + + "\n" + ); + } + + /** + * Creates an SVG string to export the current stuff on the canvas. + * + * NOTE that this currently does not include collapsed clades or barplots. + * Support for this is planned! + * + * @param {Empress} empress + * @param {Drawer} drawer + * + * @return {String} + */ + function exportTreeSVG(empress, drawer) { + /** + * Given coords and a start position in it (the start of a series of 5 + * elements), return an RGB triplet representation of the color at + * this position. + * + * @param {Array} coords + * @param {Number} i + * @return {String} + */ + var getRGB = function (coords, i) { + return chroma.gl(coords[i + 2], coords[i + 3], coords[i + 4]).css(); + }; + + var minX = Number.POSITIVE_INFINITY; + var maxX = Number.NEGATIVE_INFINITY; + var minY = Number.POSITIVE_INFINITY; + var maxY = Number.NEGATIVE_INFINITY; + var svg = "\n"; + + // create a line from x1,y1 to x2,y2 for every two consecutive + // coordinates. 5 array elements encode one coordinate: + // i=x, i+1=y, i+2=red, i+3=green, i+4=blue + var coords = empress.getCoords(); + for ( + var i = 0; + i + 2 * drawer.VERTEX_SIZE <= coords.length; + i += 2 * drawer.VERTEX_SIZE + ) { + // "normal" lines have a default color, + // all other lines have a user defined thickness + // All lines are defined using the information from the child node. + // So, if coords[i+2] == DEFAULT_COLOR then coords[i+2+5] will + // also be equal to DEFAULT_COLOR. Thus, we can save checking three + // array elements here. + // TODO: instead, adjust line width based on a node's isColored + // tree data attribute, in corner-case where dflt node color is + // included in a color map. + // (Also: I'm not confident that SVG stroke width and line width in + // the Empress visualization are comparable, at least now?) + var linewidth = 1 + empress._currentLineWidth; + if ( + coords[i + 2] == empress.DEFAULT_COLOR[0] && + coords[i + 3] == empress.DEFAULT_COLOR[1] && + coords[i + 4] == empress.DEFAULT_COLOR[2] + ) { + linewidth = 1; + } + // NOTE: we negate the y coordinates in order to match the way the + // tree is drawn. See #334 on GitHub for discussion. + var x1 = coords[i]; + var y1 = -coords[i + 1]; + var x2 = coords[i + drawer.VERTEX_SIZE]; + var y2 = -coords[i + 1 + drawer.VERTEX_SIZE]; + var color = getRGB(coords, i); + + // Add the branch to the SVG + svg += + '\n'; + + // Update bounding box based on tree coordinates + minX = Math.min(minX, x1, x2); + maxX = Math.max(maxX, x1, x2); + minY = Math.min(minY, y1, y2); + maxY = Math.max(maxY, y1, y2); + } + + // create a circle for each node + if (drawer.showTreeNodes) { + radius = drawer.NODE_CIRCLE_DIAMETER / 2; + svg += "\n"; + coords = empress.getNodeCoords(); + for ( + i = 0; + i + drawer.VERTEX_SIZE <= coords.length; + i += drawer.VERTEX_SIZE + ) { + svg += + '\n'; + } + // The edge of the bounding box should coincide with the "end" of a + // node. So we expand each side of the bounding box by the node + // radius to avoid cutting off nodes. + // (That a node has to be present at each edge of the bounding box + // isn't guaranteed, esp. when we will draw collapsed clades / + // barplots. However, even if this isn't the case, it'll just make + // the exported image very slightly larger -- not a huge deal. Best + // to be safe.) + minX -= radius; + minY -= radius; + maxX += radius; + maxY += radius; + } + return _finalizeSVG(svg, minX, minY, maxX, maxY); + } + + /** + * Creates an SVG string to export legends. + * + * @param {Array} Array of Legend objects, which will be included in the + * exported SVG. These will be ordered in the same way that + * they are ordered in the Array (so the first legend will + * be at the top, the next legend will be placed below that, + * and so on). + * + * @return {String} svg SVG code representing all the specified legends. + */ + function exportLegendSVG(legends) { + // the SVG string to be generated + var svg = ""; + + // All distances are based on this variable. The scale of the resulting + // SVG can therefore be altered by changing this value. + var unit = 30; + + // distance between two text lines as a multiplication factor of UNIT + var lineHeightScaleFactor = 1.8; + + var lineHeight = unit * lineHeightScaleFactor; + + // Count the number of used rows + var row = 1; + + // Also keep track of the maximum-width legend SVG, so that (when + // merging this SVG with the tree SVG) we can resize the viewbox + // accordingly + var maxX = 0; + var maxY = 0; + + _.each(legends, function (legend, legendIndex) { + if (legendIndex > 0) { + // Add space between adjacent legends + row++; + maxY += lineHeight; + } + var legendSVGData = legend.exportSVG(row, unit, lineHeight); + svg += legendSVGData.svg; + row = legendSVGData.rowsUsed; + // Based on the width of this legend's bounding box, try to update + // maxX. (Different legends may have different widths, so this + // isn't always going to be updated; however, we just place legends + // below each other, so maxY will always get updated.) + maxX = Math.max(maxX, legendSVGData.width); + maxY += legendSVGData.height; + }); + + // Slice off extra vertical space below the bottom legend. The height + // of this space seems to always be equal to exactly + // (# legends - 1) * unit. I think this may come from topY (in + // Legend.exportSVG()) starting at row - 1, but I haven't been able to + // get things exactly right yet. It would be good to adjust things so + // that this ugly step isn't required. + maxY -= (legends.length - 1) * unit; + + // minX and minY are always going to be 0. (In the tree export, the + // root node is (0, 0) so there are usually negative coordinates; here, + // we have the luxury of being able to keep everything positive.) + return _finalizeSVG(svg, 0, 0, maxX, maxY); + } + + /** + * Exports a PNG image of the canvas. + * + * This uses the canvas toBlob() method, which requires that the caller + * provide a callback function (to which the output blob will be passed). + * We could use async/await stuff to halt within this function until + * toBlob() produces the PNG, and then return that to the caller of this + * function, but for the sake of simplicity we just have the caller specify + * a callback to this function. Phew! + * + * NOTE: Currently, this will be limited to how the tree is currently drawn + * -- so if the user is zoomed in really far, then the exported PNG will + * just show things at the current zoom level. Ideally, we'll want to add + * an option to get around this. (Calling this.centerLayoutAvgPoint()) in + * lieu of this.drawTree() works, but the user will see the canvas shift; + * not sure if there's a way to do this without disrupting the UI.) + * + * @param {Empress} empress + * @param {Canvas} canvas + * @param {Function} callback + */ + function exportTreePNG(empress, canvas, callback) { + // Draw the tree immediately before calling toBlob(), to ensure that + // the buffer hasn't been cleared. This is analogous to "solution 1" + // for taking screenshots of a canvas in this tutorial: + // https://webglfundamentals.org/webgl/lessons/webgl-tips.html. + empress.drawTree(); + canvas.toBlob(callback); + } + + return { + exportTreeSVG: exportTreeSVG, + exportTreePNG: exportTreePNG, + exportLegendSVG: exportLegendSVG, + }; +}); diff --git a/empress/support_files/js/legend.js b/empress/support_files/js/legend.js index ba1e25450..1350063fd 100644 --- a/empress/support_files/js/legend.js +++ b/empress/support_files/js/legend.js @@ -4,8 +4,8 @@ define(["underscore", "util"], function (_, util) { * @class Legend * * Creates a legend within a given HTML element. (You'll need to call - * addCategoricalKey() or addContinuousKey() to populate the legend with - * data.) + * addCategoricalKey(), addContinuousKey(), or addLengthKey() to populate + * the legend with data.) * * Currently, this legend is only designed to show color variation. * However, extending it to show other sorts of encodings -- for example, @@ -18,12 +18,56 @@ define(["underscore", "util"], function (_, util) { * @constructs Legend */ function Legend(container) { + /** + * @type {HTMLElement} + * Reference to the element containing the legend. + * @private + */ this._container = container; + + /** + * @type {String} + * The "type" of the legend. Will be set when any of the + * Legend.add*Key() functions is called to be one of "continuous", + * "categorical", or "length". This will be null instead of a + * String before any of the add*Key() functions is called and after + * clear() is called. + * @public + */ + this.legendType = null; + + /** + * @type {String} + * Contains the current title text for the legend. Will be "" if + * addTitle() hasn't been called yet / after clear() is called. + * @public + */ + this.title = ""; + + /** + * @type {Array} + * Sorted categories shown in the legend. Stored as a class-level + * variable so it can be retrieved when exporting a categorical legend + * to SVG. + * @private + */ + this._sortedCategories = []; + + /** + * @type {Object} + * Maps categories to hex colors. Same deal as with + * this._sortedCategories -- we store this in order to make exporting + * categorical legends easier. + * @private + */ + this._category2color = {}; } /** * Adds a title element to the legend container. * + * Also updates this.title to equal the input text. + * * @param {String} name Text to show in the title. */ Legend.prototype.addTitle = function (name) { @@ -32,6 +76,7 @@ define(["underscore", "util"], function (_, util) { ); titleDiv.classList.add("legend-title"); titleDiv.innerText = name; + this.title = name; }; /** @@ -103,6 +148,7 @@ define(["underscore", "util"], function (_, util) { warningP.setAttribute("style", "white-space: normal;"); this._container.appendChild(warningP); } + this.legendType = "continuous"; this.unhide(); }; @@ -114,6 +160,11 @@ define(["underscore", "util"], function (_, util) { * using util.naturalSort() on the keys (this should match the way colors * are assigned). * + * NOTE that this will assign values to this._sortedCategories and + * this._category2color. (The values of these attributes are arbitrary when + * you call another add*Key() function -- they're intended for internal + * use within the Legend class.) + * * @param {String} name Text to show in the legend title. * @param {Object} info Color key information. This should map unique * values (e.g. in sample or feature metadata) to @@ -131,7 +182,8 @@ define(["underscore", "util"], function (_, util) { } this.clear(); this.addTitle(name); - let sortedCategories = util.naturalSort(_.keys(info)); + this._sortedCategories = util.naturalSort(_.keys(info)); + this._category2color = info; var containerTable = document.createElement("table"); // Remove border spacing, which seems to be a default for at least some // browsers. This prevents labels from appearing to the left of color @@ -139,7 +191,7 @@ define(["underscore", "util"], function (_, util) { // kinda ugly, so by smooshing the colors to the left of the legend we // avoid this problem). containerTable.setAttribute("style", "border-spacing: 0;"); - _.each(sortedCategories, function (key) { + _.each(this._sortedCategories, function (key) { var newRow = containerTable.insertRow(-1); // Add a color box (could totally be replaced by e.g. a Spectrum @@ -165,6 +217,7 @@ define(["underscore", "util"], function (_, util) { innerLabel.title = key; }); this._container.appendChild(containerTable); + this.legendType = "categorical"; this.unhide(); }; @@ -201,6 +254,7 @@ define(["underscore", "util"], function (_, util) { maxValCell.innerText = maxVal; this._container.append(infoTable); + this.legendType = "length"; this.unhide(); }; @@ -215,6 +269,168 @@ define(["underscore", "util"], function (_, util) { while (this._container.firstChild) { this._container.removeChild(this._container.firstChild); } + this.legendType = null; + this.title = ""; + }; + + /** + * Gets an SVG representation of the legend, along with some other details. + * + * Please see ExportUtil.exportLegendSVG() for details on the parameters to + * this function. + * + * @param {Number} row Current row this legend will be created on. (Long + * story short, if only one legend is getting exported + * this should just be 1. This is used to determine the + * vertical position of this legend and its elements in + * a SVG image.) + * @param {Number} unit Number used to scale all distances in the SVG. + * @param {Number} lineHeight Result of multiplying unit by some factor. + * Has to do with the distance between two text + * lines. + * + * @return {Object} Contains four keys: + * -svg: String containing the legend SVG + * -rowsUsed: Number describing the number of rows used in + * this legend (plus row) + * -width: The width of the legend SVG + * -height: The height of the legend SVG. Honestly, I + * think this is slightly too large -- not sure what's + * going on. At least things work right now! + * + * @throws {Error} If the current legend type does not have SVG export + * supported. Currently only categorical legends can be + * exported, but this will change soon. + */ + Legend.prototype.exportSVG = function (row, unit, lineHeight) { + var scope = this; + + // Style of the rect containing the legend SVG (in addition to the + // global rect style applied below) + var BGSTYLE = 'style="fill:#ffffff;"'; + + // Font style for the legend title and entries. Should match what + // Empress uses in its body CSS. + var FONT = unit + "pt Arial,Helvetica,sans-serif"; + + // Used as a rough estimate about the consumed width by text strings. + var tmpCanvas = document.createElement("canvas"); + var context = tmpCanvas.getContext("2d"); + // Fun fact: if you accidentally include a semicolon at the end of the + // font then this will break context.measureText()! No idea why, but + // that was a fun ten minutes. + context.font = "bold " + FONT; + + // Figure out the rect's top Y position + var topY = (row - 1) * lineHeight; + + // Set global styling for the SVG, cutting down a bit on redundant text + // in the output SVG. (This is based on how the vertex/fragment shader + // code in drawer.js is constructed as an array of strings.) + var legendSVG = [ + "", + ].join("\n"); + + var rowsUsed = row; + if (this.legendType === "categorical") { + var maxLineWidth = context.measureText(this.title).width; + // First, add the title to the legend SVG. + // The x="50%" and text-anchor="middle" center the title over the + // legend: solution from + // https://stackoverflow.com/a/31522006/10730311. + legendSVG += + '' + + this.title + + "\n"; + rowsUsed++; + // Go through each of the categories and add a row to the legend + // SVG. (Since the legend type is categorical, + // this._sortedCategories and this._category2color must be + // defined.) + _.each(this._sortedCategories, function (cat) { + var color = scope._category2color[cat]; + maxLineWidth = Math.max( + maxLineWidth, + context.measureText(cat).width + ); + var rowTopY = (rowsUsed - 1) * lineHeight + unit; + // Add a square to the left of the label showing the color + legendSVG += + '\n'; + // Add text labelling the category. We set dominant-baseline + // to "hanging" so that we can reference the top position of + // the text, not the bottom position of the text. (The default + // for is that y points to top, the default for + // is that y points to baseline. I don't know why...) Soln c/o + // https://stackoverflow.com/a/45914139/10730311. + legendSVG += + '' + + cat + + "\n"; + rowsUsed++; + }); + + // draw a rect behind, i.e. lower z-order, the legend title and + // colored keys to visually group the legend. Also actually put + // these elements into a group for easier manual editing. + // rect shall have a certain padding, its height must exceed + // the number of used text rows and width must be larger than + // longest key text and/or legend title + var numCats = this._sortedCategories.length; + // The maximum line width is the max text width plus (in the likely + // event that the max text width is from a category line, not from + // the title line) the width of a color square (lineHeight) plus + // the padding btwn. the color square and start of the text (unit) + var width = maxLineWidth + lineHeight + unit; + var height = (numCats + 1) * lineHeight + unit; + var outputSVG = + '\n\n" + + legendSVG + + "\n"; + return { + svg: outputSVG, + rowsUsed: rowsUsed, + width: width, + height: height, + }; + } else { + // Eventually, when we add support for exporting continuous / + // length legends, this will only really happen if someone tries to + // export a legend with an invalid legendType (e.g. null) + throw new Error( + "Only categorical legends can be exported right now." + ); + } }; return Legend; diff --git a/empress/support_files/js/node-click-callback.js b/empress/support_files/js/node-click-callback.js index 00ded1529..1b13d89d7 100644 --- a/empress/support_files/js/node-click-callback.js +++ b/empress/support_files/js/node-click-callback.js @@ -135,7 +135,7 @@ ec.controllers.color.addEventListener("value-double-clicked", function ( // if there's any coloring setup remove it, and re-enable the update button sPanel.sUpdateBtn.classList.remove("hidden"); sPanel.fUpdateBtn.classList.remove("hidden"); - legend.clear(); + empress.clearLegend(); empress.resetTree(); var names = _.map(payload.message.group, function (item) { diff --git a/empress/support_files/js/select-node-menu.js b/empress/support_files/js/select-node-menu.js index 2554eb6a3..4eb11a1e8 100644 --- a/empress/support_files/js/select-node-menu.js +++ b/empress/support_files/js/select-node-menu.js @@ -184,7 +184,11 @@ define(["underscore", "util"], function (_, util) { var node = nodeKeys[0]; var name = emp.getNodeInfo(node, "name"); - this.nodeNameLabel.textContent = "Name: " + name; + if (name === null) { + this.nodeNameLabel.textContent = "Unnamed node"; + } else { + this.nodeNameLabel.textContent = "Name: " + name; + } this.notes.textContent = ""; this.warning.textContent = ""; @@ -296,13 +300,20 @@ define(["underscore", "util"], function (_, util) { // The reason we try to figure this out here is so that we can // determine whether or not to show a warning about duplicate names // in the menu. - var keysOfNodesWithThisName = this.empress._tree.getNodesWithName(name); - if (keysOfNodesWithThisName.length > 1) { + if (name !== null) { + var keysOfNodesWithThisName = this.empress._tree.getNodesWithName( + name + ); + if (keysOfNodesWithThisName.length > 1) { + this.warning.textContent = + "Warning: " + + keysOfNodesWithThisName.length + + " nodes exist with the " + + "above name."; + } + } else { this.warning.textContent = - "Warning: " + - keysOfNodesWithThisName.length + - " nodes exist with the " + - "above name."; + "No name was provided for this node in the input tree file."; } // 1. Add feature metadata information (if present) for this node diff --git a/empress/support_files/js/selection-callback.js b/empress/support_files/js/selection-callback.js index ac7af94d9..dc524d280 100644 --- a/empress/support_files/js/selection-callback.js +++ b/empress/support_files/js/selection-callback.js @@ -10,7 +10,7 @@ clearTimeout(empress.timer); // if there's any coloring setup remove it, and re-enable the update button sPanel.sUpdateBtn.classList.remove("hidden"); sPanel.fUpdateBtn.classList.remove("hidden"); -legend.clear(); +empress.clearLegend(); empress.resetTree(); // fetch a mapping of colors to plottable objects diff --git a/empress/support_files/js/side-panel-handler.js b/empress/support_files/js/side-panel-handler.js index 80a9528aa..faafb5432 100644 --- a/empress/support_files/js/side-panel-handler.js +++ b/empress/support_files/js/side-panel-handler.js @@ -10,12 +10,11 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { * * @param {HTMLElement} container Container where the side panel will live * @param {Empress} empress Empress instance; used to redraw the tree, etc. - * @param {Legend} legend Reference to the main legend object * * @return {SidePanel} * @constructs SidePanel */ - function SidePanel(container, empress, legend) { + function SidePanel(container, empress) { // used in event closures var scope = this; @@ -31,8 +30,6 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { // used to event triggers this.empress = empress; - this.legend = legend; - // tree properties components this.treeNodesChk = document.getElementById("display-nodes-chk"); this.recenterBtn = document.getElementById("center-tree-btn"); @@ -88,7 +85,11 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { this.symmetricCladeMethod = document.getElementById("symmetric"); // export GUI components - this.eExportSvgBtn = document.getElementById("export-btn-svg"); + this.exportTreeSVGBtn = document.getElementById("export-tree-svg-btn"); + this.exportTreePNGBtn = document.getElementById("export-tree-png-btn"); + this.exportLegendSVGBtn = document.getElementById( + "export-legend-svg-btn" + ); // hides the side menu var collapse = document.getElementById(this.COLLAPSE_ID); @@ -141,7 +142,7 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { // Reset tree and then clear legend this.empress.resetTree(); this.empress.drawTree(); - this.legend.clear(); + this.empress.clearLegend(); }; /* Resets the sample metadata coloring tab. */ @@ -241,7 +242,6 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { this.sUpdateBtn.classList.remove("hidden"); return; } - this.legend.addCategoricalKey(colBy, keyInfo); }; /** @@ -251,12 +251,7 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { var colBy = this.fSel.value; var col = this.fColor.value; var coloringMethod = this.fMethodChk.checked ? "tip" : "all"; - var keyInfo = this.empress.colorByFeatureMetadata( - colBy, - col, - coloringMethod - ); - this.legend.addCategoricalKey(colBy, keyInfo); + this.empress.colorByFeatureMetadata(colBy, col, coloringMethod); }; /** @@ -330,29 +325,35 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { }; /** - * Initializes export components + * Initializes exporting options. */ SidePanel.prototype.addExportTab = function () { // for use in closures var scope = this; - this.eExportSvgBtn.onclick = function () { - // create SVG tags to draw the tree and determine viewbox for whole figure - [svg_tree, svg_viewbox] = scope.empress.exportSvg(); - // create SVG tags for legend, collected from the HTML document - svg_legend = scope.empress.exportSVG_legend(document); - // add all SVG elements into one string ... - svg = - '\n" + - svg_tree + - "\n" + - svg_legend + - "\n"; - // ... and present user as a downloadable file + // Presents SVG to user as a downloadable file + var saveSVGBlob = function (svg, filename) { var blob = new Blob([svg], { type: "image/svg+xml" }); - saveAs(blob, "empress-tree.svg"); + saveAs(blob, filename); + }; + + this.exportTreeSVGBtn.onclick = function () { + var svg = scope.empress.exportTreeSVG(); + saveSVGBlob(svg, "empress-tree.svg"); + }; + this.exportTreePNGBtn.onclick = function () { + var callback = function (blob) { + saveAs(blob, "empress-tree.png"); + }; + scope.empress.exportTreePNG(callback); + }; + this.exportLegendSVGBtn.onclick = function () { + var svg = scope.empress.exportLegendSVG(); + // If no legends are currently shown, exportLegendSVG() will just + // return null -- in which case nothing more needs to be done. + if (svg !== null) { + saveSVGBlob(svg, "empress-legends.svg"); + } }; }; diff --git a/empress/support_files/templates/empress-template.html b/empress/support_files/templates/empress-template.html index a7eca7bad..67483aa5c 100644 --- a/empress/support_files/templates/empress-template.html +++ b/empress/support_files/templates/empress-template.html @@ -102,6 +102,7 @@ 'SelectedNodeMenu' : './js/select-node-menu', 'util' : './js/util', 'LayoutsUtil': './js/layouts-util', + 'ExportUtil': './js/export-util', } }); @@ -110,11 +111,12 @@ 'SidePanel', 'AnimationPanel', 'Animator', 'BarplotLayer', 'BarplotPanel', 'BIOMTable', 'Empress', 'Legend', 'Colorer', 'VectorOps', 'CanvasEvents', 'SelectedNodeMenu', - 'util', 'LayoutsUtil'], + 'util', 'LayoutsUtil', 'ExportUtil'], function($, gl, chroma, underscore, spectrum, filesaver, ByteArray, BPTree, Camera, Drawer, SidePanel, AnimationPanel, Animator, BarplotLayer, BarplotPanel, BIOMTable, Empress, Legend, - Colorer, VectorOps, CanvasEvents, SelectedNodeMenu, util, LayoutsUtil) { + Colorer, VectorOps, CanvasEvents, SelectedNodeMenu, util, + LayoutsUtil, ExportUtil) { // initialze the tree and model var tree = new BPTree( {{ tree }}, @@ -145,11 +147,9 @@ ); empress.initialize(); - var legend = new Legend(document.getElementById('legend-main')); - // The side menu var sPanel = new SidePanel(document.getElementById('side-panel'), - empress, legend); + empress); sPanel.addTreePropertiesTab(); sPanel.addSampleTab(); if (fmCols.length > 0) { @@ -159,7 +159,7 @@ sPanel.addLayoutTab(); // Create animator state machine - var animator = new Animator(empress, legend); + var animator = new Animator(empress); // Add animator GUI components var animationPanel = new AnimationPanel(animator); diff --git a/empress/support_files/templates/side-panel.html b/empress/support_files/templates/side-panel.html index 87ec1a5c7..b6e4b3c31 100644 --- a/empress/support_files/templates/side-panel.html +++ b/empress/support_files/templates/side-panel.html @@ -218,11 +218,20 @@ @@ -234,12 +243,10 @@

- +

- If checked, this draws circles at the positions of all tree nodes - which had a specified name in the input Newick file. + If checked, this draws circles at all nodes' positions.


diff --git a/empress/tools.py b/empress/tools.py index 35ac15c32..fba8f93b0 100644 --- a/empress/tools.py +++ b/empress/tools.py @@ -8,8 +8,6 @@ import warnings import pandas as pd -import skbio -from skbio import TreeNode from empress import taxonomy_utils from empress.tree import bp_tree_tips, bp_tree_non_tips from itertools import zip_longest @@ -23,16 +21,6 @@ class DataMatchingWarning(Warning): pass -def read(file_name, file_format='newick'): - """ Reads in contents from a file. - """ - - if file_format == 'newick': - tree = skbio.read(file_name, file_format, into=TreeNode) - return tree - return None - - def match_inputs( bp_tree, table, diff --git a/tests/index.html b/tests/index.html index 3c7262de8..b9c2d08c6 100644 --- a/tests/index.html +++ b/tests/index.html @@ -48,6 +48,10 @@

+ +
+
+
@@ -114,6 +118,7 @@ 'CanvasEvents' : './support_files/js/canvas-events', 'SelectedNodeMenu' : './support_files/js/select-node-menu', 'LayoutsUtil' : './support_files/js/layouts-util', + 'ExportUtil' : './support_files/js/export-util', /* test utility code */ 'UtilitiesForTesting' : './../tests/utilities-for-testing', @@ -155,6 +160,7 @@ 'Empress', 'Legend', 'LayoutsUtil', + 'ExportUtil', 'UtilitiesForTesting', 'testBPTree', 'testByteTree', @@ -192,6 +198,7 @@ Empress, Legend, LayoutsUtil, + ExportUtil, UtilitiesForTesting, testBPTree, testByteTree, diff --git a/tests/test-circular-layout-computation.js b/tests/test-circular-layout-computation.js index 806054966..bf707aa4e 100644 --- a/tests/test-circular-layout-computation.js +++ b/tests/test-circular-layout-computation.js @@ -228,7 +228,6 @@ require(["jquery", "BPTree", "BiomTable", "Empress"], function ( // prettier-ignore ok(Math.abs(coords[625] - (-2)) < 1.0e-15); // end x arc position ok(Math.abs(coords[626] - 0 < 1.0e-15)); // end y arc position - console.log(coords); }); }); }); diff --git a/tests/test-empress.js b/tests/test-empress.js index fe8ef70fd..d1dbd8a44 100644 --- a/tests/test-empress.js +++ b/tests/test-empress.js @@ -56,8 +56,9 @@ require(["jquery", "UtilitiesForTesting", "util", "chroma"], function ( }); test("Test getNodeCoords", function () { - // Note: node 6's name is null which means it will not be - // included in the getNodeCoords() + // Note: node 6's name is null, which would indicate that it didn't + // have an assigned name in the input Newick file. However, for + // #348, we still want to draw a circle for it. // prettier-ignore var rectCoords = new Float32Array([ 1, 2, 0.75, 0.75, 0.75, @@ -65,6 +66,8 @@ require(["jquery", "UtilitiesForTesting", "util", "chroma"], function ( 5, 6, 0.75, 0.75, 0.75, 7, 8, 0.75, 0.75, 0.75, 9, 10, 0.75, 0.75, 0.75, + // This next row contains coordinate data for node 6 + 11, 12, 0.75, 0.75, 0.75, 13, 14, 0.75, 0.75, 0.75, ]); this.empress._currentLayout = "Rectangular"; @@ -78,6 +81,7 @@ require(["jquery", "UtilitiesForTesting", "util", "chroma"], function ( 19, 20, 0.75, 0.75, 0.75, 21, 22, 0.75, 0.75, 0.75, 23, 24, 0.75, 0.75, 0.75, + 25, 26, 0.75, 0.75, 0.75, 27, 28, 0.75, 0.75, 0.75, ]); this.empress._currentLayout = "Circular"; @@ -91,6 +95,7 @@ require(["jquery", "UtilitiesForTesting", "util", "chroma"], function ( 33, 34, 0.75, 0.75, 0.75, 35, 36, 0.75, 0.75, 0.75, 37, 38, 0.75, 0.75, 0.75, + 39, 40, 0.75, 0.75, 0.75, 41, 42, 0.75, 0.75, 0.75, ]); this.empress._currentLayout = "Unrooted"; diff --git a/tests/test-legend.js b/tests/test-legend.js index 4e11d5e83..62b643ffe 100644 --- a/tests/test-legend.js +++ b/tests/test-legend.js @@ -20,10 +20,10 @@ require(["jquery", "chroma", "UtilitiesForTesting", "Legend"], function ( ); // When rendering the SVG on the page, the browser replaces // with (and - // with with ") .join("/>") @@ -55,6 +55,11 @@ require(["jquery", "chroma", "UtilitiesForTesting", "Legend"], function ( var legend = new Legend(this.containerEle); equal(this.containerEle.firstChild, funkyP); }); + test('On initialization, legendType is null and title is ""', function () { + var legend = new Legend(this.containerEle); + equal(legend.legendType, null); + equal(legend.title, ""); + }); test("addCategoricalKey", function () { var legend = new Legend(this.containerEle); var colorInfo = { @@ -66,12 +71,16 @@ require(["jquery", "chroma", "UtilitiesForTesting", "Legend"], function ( }; legend.addCategoricalKey("qwerty", colorInfo); + // Check that the legend type was set correctly + equal(legend.legendType, "categorical"); + // There should only be two top-level elements added to the legend // container element equal(this.containerEle.children.length, 2); // The first of these child elements should be a title this.validateTitleEle(this.containerEle.children[0], "qwerty"); + equal(legend.title, "qwerty"); // The second is a table containing the color map var tbl = this.containerEle.children[1]; @@ -108,6 +117,16 @@ require(["jquery", "chroma", "UtilitiesForTesting", "Legend"], function ( }); // Legend should be visible notOk(this.containerEle.classList.contains("hidden")); + + // Check that _sortedCategories and _category2color are defined + deepEqual(legend._sortedCategories, [ + "Thing 1", + "Thing 2", + "Thing 3", + "Thing 4", + "Thing 5", + ]); + deepEqual(legend._category2color, colorInfo); }); test("addCategoricalKey (just 1 color)", function () { var legend = new Legend(this.containerEle); @@ -115,8 +134,11 @@ require(["jquery", "chroma", "UtilitiesForTesting", "Legend"], function ( var colorInfo = { hjkl: darkBrown }; legend.addCategoricalKey("Single-color test", colorInfo); + equal(legend.legendType, "categorical"); + var title = this.containerEle.children[0]; equal(title.innerText, "Single-color test"); + equal(legend.title, "Single-color test"); var tbl = this.containerEle.children[1]; var rows = $(tbl).find("tr"); @@ -124,6 +146,10 @@ require(["jquery", "chroma", "UtilitiesForTesting", "Legend"], function ( var cells = $(rows[0]).children(); equal(chroma($(cells[0]).css("background")).hex(), darkBrown); equal(cells[1].innerText, "hjkl"); + + // Check that _sortedCategories and _category2color are defined + deepEqual(legend._sortedCategories, ["hjkl"]); + deepEqual(legend._category2color, colorInfo); }); test("addCategoricalKey (error: no categories)", function () { var legend = new Legend(this.containerEle); @@ -140,6 +166,8 @@ require(["jquery", "chroma", "UtilitiesForTesting", "Legend"], function ( false ); + equal(legend.legendType, "continuous"); + // As with addCategoricalKey(), there are two children added to the // top level of the container element. equal(this.containerEle.children.length, 2); @@ -149,6 +177,7 @@ require(["jquery", "chroma", "UtilitiesForTesting", "Legend"], function ( this.containerEle.children[0], "OMG this is a continuous legend!" ); + equal(legend.title, "OMG this is a continuous legend!"); // 2. A "container SVG" element containing the gradient SVG var cSVG = this.containerEle.children[1]; @@ -162,12 +191,15 @@ require(["jquery", "chroma", "UtilitiesForTesting", "Legend"], function ( var refSVG = UtilitiesForTesting.getReferenceSVG(); legend.addContinuousKey("howdy", refSVG, true); + equal(legend.legendType, "continuous"); + // There's a third top-level child element now -- a warning // message shown to the user. equal(this.containerEle.children.length, 3); // 1. Check title this.validateTitleEle(this.containerEle.children[0], "howdy"); + equal(legend.title, "howdy"); // 2. Check SVG var cSVG = this.containerEle.children[1]; @@ -194,6 +226,9 @@ require(["jquery", "chroma", "UtilitiesForTesting", "Legend"], function ( var legend = new Legend(this.containerEle); legend.addLengthKey("LengthTest :O", -5.12345, 1000); + equal(legend.legendType, "length"); + + equal(legend.title, "LengthTest :O"); var title = this.containerEle.children[0]; equal(title.innerText, "LengthTest :O"); @@ -227,11 +262,18 @@ require(["jquery", "chroma", "UtilitiesForTesting", "Legend"], function ( ); wackyDiv.innerText = "I'm here to test that clear removes all children"; + legend.legendType = + "I'm here to test that legendType is reset on clearing"; + legend.title = "I'm here to test that title is reset on clearing"; legend.clear(); // The legend container should now be hidden ok(this.containerEle.classList.contains("hidden")); // ... and all of its child elements should be removed equal(this.containerEle.firstChild, null); + // ... and the legendType should be null + equal(legend.legendType, null); + // ... and the title should be "" + equal(legend.title, ""); }); test("unhide", function () { var legend = new Legend(this.containerEle); @@ -244,21 +286,21 @@ require(["jquery", "chroma", "UtilitiesForTesting", "Legend"], function ( }); test("addTitle", function () { var legend = new Legend(this.containerEle); - legend.addTitle("Hi I'm a title"); + var titleText1 = "Hi I'm a title"; + legend.addTitle(titleText1); equal(this.containerEle.children.length, 1); - this.validateTitleEle( - this.containerEle.children[0], - "Hi I'm a title" - ); + this.validateTitleEle(this.containerEle.children[0], titleText1); + equal(legend.title, titleText1); // Note that addTitle() sets the text using innerText, so HTML in // the text should be treated as just part of the string - var titleText = + var titleText2 = "Two titles? In my
? It's more " + "likely than you think."; - legend.addTitle(titleText); + legend.addTitle(titleText2); equal(this.containerEle.children.length, 2); - this.validateTitleEle(this.containerEle.children[1], titleText); + this.validateTitleEle(this.containerEle.children[1], titleText2); + equal(legend.title, titleText2); }); }); });