From 69ad23a334fb4f9bcbfad768f89c477eb61c9a62 Mon Sep 17 00:00:00 2001 From: Stefan Janssen Date: Tue, 28 Jul 2020 22:33:59 +0200 Subject: [PATCH] Export svg (#218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * adding a function that generates a SVG string from coords and NodeCoords * adding button to trigger export * add new vendor js dependency (as used in Emperor) * add GUI element for export SVG to side-panel * adding new dependency * fix codestyle * export line thickness * prettier * prettier * plot nodes in generates SVG only if "show node circles" is true * adding first two js-tests * adding more tests * using global size variable for node radius * adressing suggestions from code review * adding a function to create a SVG version for a legend * execute legend to SVG code * generate svg separately for tree and legend due to internal information flow. * prettier * adept to new Biom table and tree format * codestyle * file is now "prettier" * Made a few minor changes * STY: Prettyfy Co-authored-by: Yoshiki Vázquez Baeza --- empress/support_files/js/empress.js | 244 ++++++++++++++++++ .../support_files/js/side-panel-handler.js | 30 +++ .../templates/empress-template.html | 10 +- .../support_files/templates/side-panel.html | 10 + empress/support_files/vendor/FileSaver.min.js | 2 + tests/index.html | 8 +- tests/test-export.js | 0 7 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 empress/support_files/vendor/FileSaver.min.js create mode 100644 tests/test-export.js diff --git a/empress/support_files/js/empress.js b/empress/support_files/js/empress.js index 22fe2f879..18f58d829 100644 --- a/empress/support_files/js/empress.js +++ b/empress/support_files/js/empress.js @@ -211,6 +211,250 @@ define([ this._drawer.draw(); }; + /** + * Creates an SVG string to export the current drawing + */ + 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] + ); + + 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] + ); + } + + // 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'; + } + } + + // 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 + */ + 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.getX = function (nodeObj) { var xname = "x" + this._layoutToCoordSuffix[this._currentLayout]; return nodeObj[xname]; diff --git a/empress/support_files/js/side-panel-handler.js b/empress/support_files/js/side-panel-handler.js index 86750d430..55451c951 100644 --- a/empress/support_files/js/side-panel-handler.js +++ b/empress/support_files/js/side-panel-handler.js @@ -56,6 +56,9 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { // layout GUI components this.layoutDiv = document.getElementById("layout-div"); + // export GUI components + this.eExportSvgBtn = document.getElementById("export-btn-svg"); + // uncheck button this.sHideChk.checked = false; @@ -301,6 +304,33 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { } }; + /** + * Initializes export components + */ + 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 + var blob = new Blob([svg], { type: "image/svg+xml" }); + saveAs(blob, "empress-tree.svg"); + }; + }; + /** * Initializes sample components */ diff --git a/empress/support_files/templates/empress-template.html b/empress/support_files/templates/empress-template.html index 81c989fd7..f75a9c4ca 100644 --- a/empress/support_files/templates/empress-template.html +++ b/empress/support_files/templates/empress-template.html @@ -82,6 +82,7 @@ 'glMatrix' : './vendor/gl-matrix.min', 'chroma' : './vendor/chroma.min', 'underscore' : './vendor/underscore-min', + 'filesaver': './vendor/FileSaver.min', 'ByteArray' : './js/byte-array', 'BPTree' : './js/bp-tree', @@ -104,10 +105,12 @@ empressRequire(['glMatrix', 'chroma', 'underscore', 'ByteArray', 'BPTree', 'Camera', 'Drawer', 'SidePanel', 'AnimationPanel', 'Animator', 'BIOMTable', 'Empress', 'Legend', 'Colorer', - 'VectorOps', 'CanvasEvents', 'SelectedNodeMenu', 'util'], + 'VectorOps', 'CanvasEvents', 'SelectedNodeMenu', 'util', + 'filesaver'], function(gl, chroma, underscore, ByteArray, BPTree, Camera, Drawer, SidePanel, AnimationPanel, Animator, BIOMTable, Empress, Legend, - Colorer, VectorOps, CanvasEvents, SelectedNodeMenu, util) { + Colorer, VectorOps, CanvasEvents, SelectedNodeMenu, util, + filesaver) { // initialze the tree and model var tree = new BPTree({{ tree }}, {{ names }}); @@ -160,6 +163,9 @@ var animationPanel = new AnimationPanel(animator); animationPanel.addAnimationTab(); + // add export drawing tab + sPanel.addExportTab(); + // make all tabs collapsable document.querySelectorAll(".collapsible").forEach(function(btn) { btn.addEventListener("click", function() { diff --git a/empress/support_files/templates/side-panel.html b/empress/support_files/templates/side-panel.html index 3086c48d3..e4d70c086 100644 --- a/empress/support_files/templates/side-panel.html +++ b/empress/support_files/templates/side-panel.html @@ -161,6 +161,16 @@ + + + diff --git a/empress/support_files/vendor/FileSaver.min.js b/empress/support_files/vendor/FileSaver.min.js new file mode 100644 index 000000000..6268ec99d --- /dev/null +++ b/empress/support_files/vendor/FileSaver.min.js @@ -0,0 +1,2 @@ +/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */ +var saveAs=saveAs||function(e){"use strict";if("undefined"==typeof navigator||!/MSIE [1-9]\./.test(navigator.userAgent)){var t=e.document,n=function(){return e.URL||e.webkitURL||e},o=t.createElementNS("http://www.w3.org/1999/xhtml","a"),r="download"in o,i=function(e){var t=new MouseEvent("click");e.dispatchEvent(t)},a=/Version\/[\d\.]+.*Safari/.test(navigator.userAgent),c=e.webkitRequestFileSystem,f=e.requestFileSystem||c||e.mozRequestFileSystem,u=function(t){(e.setImmediate||e.setTimeout)(function(){throw t},0)},d="application/octet-stream",s=0,l=4e4,v=function(e){var t=function(){"string"==typeof e?n().revokeObjectURL(e):e.remove()};setTimeout(t,l)},p=function(e,t,n){t=[].concat(t);for(var o=t.length;o--;){var r=e["on"+t[o]];if("function"==typeof r)try{r.call(e,n||e)}catch(i){u(i)}}},w=function(e){return/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(e.type)?new Blob(["\ufeff",e],{type:e.type}):e},y=function(t,u,l){l||(t=w(t));var y,m,S,h=this,R=t.type,O=!1,g=function(){p(h,"writestart progress write writeend".split(" "))},b=function(){if(m&&a&&"undefined"!=typeof FileReader){var o=new FileReader;return o.onloadend=function(){var e=o.result;m.location.href="data:attachment/file"+e.slice(e.search(/[,;]/)),h.readyState=h.DONE,g()},o.readAsDataURL(t),void(h.readyState=h.INIT)}if((O||!y)&&(y=n().createObjectURL(t)),m)m.location.href=y;else{var r=e.open(y,"_blank");void 0===r&&a&&(e.location.href=y)}h.readyState=h.DONE,g(),v(y)},E=function(e){return function(){return h.readyState!==h.DONE?e.apply(this,arguments):void 0}},N={create:!0,exclusive:!1};return h.readyState=h.INIT,u||(u="download"),r?(y=n().createObjectURL(t),void setTimeout(function(){o.href=y,o.download=u,i(o),g(),v(y),h.readyState=h.DONE})):(e.chrome&&R&&R!==d&&(S=t.slice||t.webkitSlice,t=S.call(t,0,t.size,d),O=!0),c&&"download"!==u&&(u+=".download"),(R===d||c)&&(m=e),f?(s+=t.size,void f(e.TEMPORARY,s,E(function(e){e.root.getDirectory("saved",N,E(function(e){var n=function(){e.getFile(u,N,E(function(e){e.createWriter(E(function(n){n.onwriteend=function(t){m.location.href=e.toURL(),h.readyState=h.DONE,p(h,"writeend",t),v(e)},n.onerror=function(){var e=n.error;e.code!==e.ABORT_ERR&&b()},"writestart progress write abort".split(" ").forEach(function(e){n["on"+e]=h["on"+e]}),n.write(t),h.abort=function(){n.abort(),h.readyState=h.DONE},h.readyState=h.WRITING}),b)}),b)};e.getFile(u,{create:!1},E(function(e){e.remove(),n()}),E(function(e){e.code===e.NOT_FOUND_ERR?n():b()}))}),b)}),b)):void b())},m=y.prototype,S=function(e,t,n){return new y(e,t,n)};return"undefined"!=typeof navigator&&navigator.msSaveOrOpenBlob?function(e,t,n){return n||(e=w(e)),navigator.msSaveOrOpenBlob(e,t||"download")}:(m.abort=function(){var e=this;e.readyState=e.DONE,p(e,"abort")},m.readyState=m.INIT=0,m.WRITING=1,m.DONE=2,m.error=m.onwritestart=m.onprogress=m.onwrite=m.onabort=m.onerror=m.onwriteend=null,S)}}("undefined"!=typeof self&&self||"undefined"!=typeof window&&window||this.content);"undefined"!=typeof module&&module.exports?module.exports.saveAs=saveAs:"undefined"!=typeof define&&null!==define&&null!==define.amd&&define([],function(){return saveAs}); \ No newline at end of file diff --git a/tests/index.html b/tests/index.html index 6d89a8ba6..56b343f97 100644 --- a/tests/index.html +++ b/tests/index.html @@ -44,6 +44,7 @@ 'glMatrix' : './support_files/vendor/gl-matrix.min', 'chroma' : './support_files/vendor/chroma.min', 'underscore' : './support_files/vendor/underscore-min', + 'filesaver': './support_files/vendor/FileSaver.min', /* succinct tree paths */ 'AnimationPanel': './support_files/js/animation-panel-handler', @@ -71,6 +72,7 @@ 'testCircularLayoutComputation' : './../tests/test-circular-layout-computation', 'testVectorOps' : './../tests/test-vector-ops', 'testEmpress' : './../tests/test-empress', + 'testExport': './../tests/test-export', 'testAnimationHandler': './../tests/test-animation-panel-handler' } }); @@ -100,7 +102,8 @@ 'testCircularLayoutComputation', 'testVectorOps', 'testEmpress', - 'testAnimationHandler' + 'testAnimationHandler', + 'testExport' ], // start tests @@ -128,7 +131,8 @@ testCircularLayoutComputation, testVectorOps, testEmpress, - testAnimationHandler + testAnimationHandler, + testExport ) { $(document).ready(function() { QUnit.start(); diff --git a/tests/test-export.js b/tests/test-export.js new file mode 100644 index 000000000..e69de29bb