diff --git a/bower.json b/bower.json index 7c913754cf3..670cbd4b70c 100644 --- a/bower.json +++ b/bower.json @@ -18,6 +18,8 @@ "node-uuid": "^1.4.7", "comma-separated-values": "^3.6.4", "FileSaver.js": "^0.0.2", - "zepto": "^1.1.6" + "zepto": "^1.1.6", + "html2canvas": "^0.4.1", + "jspdf": "^1.2.61" } } diff --git a/main.js b/main.js index 268c70060d9..58ea2b1756e 100644 --- a/main.js +++ b/main.js @@ -28,6 +28,8 @@ requirejs.config({ "angular-route": "bower_components/angular-route/angular-route.min", "csv": "bower_components/comma-separated-values/csv.min", "es6-promise": "bower_components/es6-promise/promise.min", + "html2canvas": "bower_components/html2canvas/build/html2canvas.min", + "jsPDF": "bower_components/jspdf/dist/jspdf.min", "moment": "bower_components/moment/moment", "moment-duration-format": "bower_components/moment-duration-format/lib/moment-duration-format", "saveAs": "bower_components/FileSaver.js/FileSaver.min", @@ -43,6 +45,12 @@ requirejs.config({ "angular-route": { "deps": ["angular"] }, + "html2canvas": { + "exports": "html2canvas" + }, + "jsPDF": { + "exports": "jsPDF" + }, "moment-duration-format": { "deps": ["moment"] }, diff --git a/platform/features/plot/bundle.js b/platform/features/plot/bundle.js index 3ce136abdfd..691e3107918 100644 --- a/platform/features/plot/bundle.js +++ b/platform/features/plot/bundle.js @@ -25,6 +25,7 @@ define([ "./src/PlotController", "./src/policies/PlotViewPolicy", "./src/PlotOptionsController", + "./src/services/ExportImageService", "text!./res/templates/plot.html", "text!./res/templates/plot-options-browse.html", 'legacyRegistry' @@ -33,6 +34,7 @@ define([ PlotController, PlotViewPolicy, PlotOptionsController, + exportImageService, plotTemplate, plotOptionsBrowseTemplate, legacyRegistry @@ -70,6 +72,8 @@ define([ "implementation": PlotController, "depends": [ "$scope", + "$element", + "exportImageService", "telemetryFormatter", "telemetryHandler", "throttle", @@ -84,12 +88,30 @@ define([ ] } ], + "services": [ + { + "key": "exportImageService", + "implementation": exportImageService, + "depends": [ + "$q", + "$timeout", + "$log", + "EXPORT_IMAGE_TIMEOUT" + ] + + } + ], "constants": [ { "key": "PLOT_FIXED_DURATION", "value": 900000, "priority": "fallback", "comment": "Fifteen minutes." + }, + { + "key": "EXPORT_IMAGE_TIMEOUT", + "value": 500, + "priority": "fallback" } ], "policies": [ @@ -103,6 +125,38 @@ define([ "key": "plot-options-browse", "template": plotOptionsBrowseTemplate } + ], + "licenses": [ + { + "name": "FileSaver.js", + "version": "0.0.2", + "author": "Eli Grey", + "description": "File download initiator (for file exports)", + "website": "https://github.com/eligrey/FileSaver.js/", + "copyright": "Copyright © 2015 Eli Grey.", + "license": "license-mit", + "link": "https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md" + }, + { + "name": "html2canvas", + "version": "0.4.1", + "author": "Niklas von Hertzen", + "description": "JavaScript HTML renderer", + "website": "https://github.com/niklasvh/html2canvas", + "copyright": "Copyright © 2012 Niklas von Hertzen.", + "license": "license-mit", + "link": "https://github.com/niklasvh/html2canvas/blob/master/LICENSE" + }, + { + "name": "jsPDF", + "version": "1.2.61", + "author": "James Hall", + "description": "JavaScript HTML renderer", + "website": "https://github.com/MrRio/jsPDF", + "copyright": "Copyright © 2010-2016 James Hall", + "license": "license-mit", + "link": "https://github.com/MrRio/jsPDF/blob/master/MIT-LICENSE.txt" + } ] } }); diff --git a/platform/features/plot/res/templates/plot.html b/platform/features/plot/res/templates/plot.html index 6dc3cd71b9e..e25b582044d 100644 --- a/platform/features/plot/res/templates/plot.html +++ b/platform/features/plot/res/templates/plot.html @@ -20,120 +20,141 @@ at runtime from the About dialog for additional information. --> -
-
- + class="abs holder holder-plot has-control-bar"> + +
+
+
+ + class='plot-legend-item' + ng-repeat="telemetryObject in subplot.getTelemetryObjects()" + ng-class="plot.getLegendClass(telemetryObject)"> {{telemetryObject.getModel().name}} -
-
- {{subplot.getHoverCoordinates()}} -
-
-
- {{axes[1].active.name}}
-
- {{tick.label | reverse}} +
+ {{subplot.getHoverCoordinates()}}
-
-
- +
+
+ {{axes[1].active.name}} +
+
+ {{tick.label | reverse}} +
+
+
+ +
-
-
- - -
-
-
-
-
-
- - - -
- - - - -
-
-
-
- {{tick.label | reverse}} -
-
- {{axes[0].active.name}} -
-
-
- +
+
+ {{tick.label | reverse}} +
+
+ {{axes[0].active.name}} +
+
+
+ +
-
+
diff --git a/platform/features/plot/src/GLChart.js b/platform/features/plot/src/GLChart.js index 56ca29bb3e6..0ca7776171d 100644 --- a/platform/features/plot/src/GLChart.js +++ b/platform/features/plot/src/GLChart.js @@ -54,7 +54,8 @@ define( * @throws {Error} an error is thrown if WebGL is unavailable. */ function GLChart(canvas) { - var gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"), + var gl = canvas.getContext("webgl", { preserveDrawingBuffer: true }) || + canvas.getContext("experimental-webgl", { preserveDrawingBuffer: true }), vertexShader, fragmentShader, program, diff --git a/platform/features/plot/src/PlotController.js b/platform/features/plot/src/PlotController.js index 88d82ea31bd..3b23bce34ae 100644 --- a/platform/features/plot/src/PlotController.js +++ b/platform/features/plot/src/PlotController.js @@ -63,6 +63,8 @@ define( */ function PlotController( $scope, + $element, + exportImageService, telemetryFormatter, telemetryHandler, throttle, @@ -246,6 +248,8 @@ define( }); self.pending = true; + self.$element = $element; + self.exportImageService = exportImageService; // Initialize axes; will get repopulated when telemetry // metadata becomes available. @@ -364,6 +368,39 @@ define( return this.pending; }; + /** + * Export the plot to PDF + */ + PlotController.prototype.exportPDF = function () { + var self = this; + self.hideExportButtons = true; + self.exportImageService.exportPDF(self.$element[0], "plot.pdf").finally(function () { + self.hideExportButtons = false; + }); + }; + + /** + * Export the plot to PNG + */ + PlotController.prototype.exportPNG = function () { + var self = this; + self.hideExportButtons = true; + self.exportImageService.exportPNG(self.$element[0], "plot.png").finally(function () { + self.hideExportButtons = false; + }); + }; + + /** + * Export the plot to JPG + */ + PlotController.prototype.exportJPG = function () { + var self = this; + self.hideExportButtons = true; + self.exportImageService.exportJPG(self.$element[0], "plot.jpg").finally(function () { + self.hideExportButtons = false; + }); + }; + return PlotController; } ); diff --git a/platform/features/plot/src/services/ExportImageService.js b/platform/features/plot/src/services/ExportImageService.js new file mode 100644 index 00000000000..d49adc15eca --- /dev/null +++ b/platform/features/plot/src/services/ExportImageService.js @@ -0,0 +1,176 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2016, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/** + * Module defining ExportImageService. Created by hudsonfoo on 09/02/16 + */ +define( + [ + "html2canvas", + "jsPDF", + "saveAs" + ], + function ( + html2canvas, + jsPDF, + saveAs + ) { + var self = this; + + /** + * The export image service will export any HTML node to + * PDF, JPG, or PNG. + * @param {object} $q + * @param {object} $timeout + * @param {object} $log + * @param {constant} EXPORT_IMAGE_TIMEOUT time in milliseconds before a timeout error is returned + * @constructor + */ + function ExportImageService($q, $timeout, $log, EXPORT_IMAGE_TIMEOUT, injHtml2Canvas, injJsPDF, injSaveAs, injFileReader) { + self.$q = $q; + self.$timeout = $timeout; + self.$log = $log; + self.EXPORT_IMAGE_TIMEOUT = EXPORT_IMAGE_TIMEOUT; + self.html2canvas = injHtml2Canvas || html2canvas; + self.jsPDF = injJsPDF || jsPDF; + self.saveAs = injSaveAs || saveAs; + self.reader = injFileReader || new FileReader(); + } + + /** + * Renders an HTML element into a base64 encoded image + * as a BLOB, PNG, or JPG. + * @param {node} element that will be converted to an image + * @param {string} type of image to convert the element to + * @returns {promise} + */ + function renderElement(element, type) { + var defer = self.$q.defer(), + validTypes = ["png", "jpg", "jpeg"], + renderTimeout; + + if (validTypes.indexOf(type) === -1) { + self.$log.error("Invalid type requested. Try: (" + validTypes.join(",") + ")"); + return; + } + + renderTimeout = self.$timeout(function () { + defer.reject("html2canvas timed out"); + self.$log.warn("html2canvas timed out"); + }, self.EXPORT_IMAGE_TIMEOUT); + + try { + self.html2canvas(element, { + onrendered: function (canvas) { + switch (type.toLowerCase()) { + case "png": + canvas.toBlob(defer.resolve, "image/png"); + break; + + default: + case "jpg": + case "jpeg": + canvas.toBlob(defer.resolve, "image/jpeg"); + break; + } + } + }); + } catch (e) { + defer.reject(e); + self.$log.warn("html2canvas failed with error: " + e); + } + + defer.promise.finally(renderTimeout.cancel); + + return defer.promise; + } + + /** + * canvas.toBlob() not supported in IE < 10, Opera, and Safari. This polyfill + * implements the method in browsers that would not otherwise support it. + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob + */ + function polyfillToBlob() { + if (!HTMLCanvasElement.prototype.toBlob) { + Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", { + value: function (callback, type, quality) { + + var binStr = atob(this.toDataURL(type, quality).split(',')[1]), + len = binStr.length, + arr = new Uint8Array(len); + + for (var i = 0; i < len; i++) { + arr[i] = binStr.charCodeAt(i); + } + + callback(new Blob([arr], {type: type || "image/png"})); + } + }); + } + } + + /** + * Takes a screenshot of a DOM node and exports to PDF. + * @param {node} element to be exported + * @param {string} filename the exported image + * @returns {promise} + */ + ExportImageService.prototype.exportPDF = function (element, filename) { + return renderElement(element, "jpeg").then(function (img) { + self.reader.readAsDataURL(img); + self.reader.onloadend = function () { + var pdf = new self.jsPDF("l", "px", [element.offsetHeight, element.offsetWidth]); + pdf.addImage(self.reader.result, "JPEG", 0, 0, element.offsetWidth, element.offsetHeight); + pdf.save(filename); + }; + }); + }; + + /** + * Takes a screenshot of a DOM node and exports to JPG. + * @param {node} element to be exported + * @param {string} filename the exported image + * @returns {promise} + */ + ExportImageService.prototype.exportJPG = function (element, filename) { + return renderElement(element, "jpeg").then(function (img) { + self.saveAs(img, filename); + }); + }; + + /** + * Takes a screenshot of a DOM node and exports to PNG. + * @param {node} element to be exported + * @param {string} filename the exported image + * @returns {promise} + */ + ExportImageService.prototype.exportPNG = function (element, filename) { + return renderElement(element, "png").then(function (img) { + self.saveAs(img, filename); + }); + }; + + polyfillToBlob(); + + return ExportImageService; + } +); diff --git a/platform/features/plot/test/PlotControllerSpec.js b/platform/features/plot/test/PlotControllerSpec.js index a3de710bdfe..73a01d716a7 100644 --- a/platform/features/plot/test/PlotControllerSpec.js +++ b/platform/features/plot/test/PlotControllerSpec.js @@ -1,3 +1,5 @@ +/*global angular*/ + /***************************************************************************** * Open MCT, Copyright (c) 2014-2016, United States Government * as represented by the Administrator of the National Aeronautics and Space @@ -29,6 +31,8 @@ define( describe("The plot controller", function () { var mockScope, + mockElement, + mockExportImageService, mockFormatter, mockHandler, mockThrottle, @@ -65,6 +69,11 @@ define( "$scope", ["$watch", "$on", "$emit"] ); + mockElement = angular.element('
'); + mockExportImageService = jasmine.createSpyObj( + "ExportImageService", + ["exportJPG", "exportPNG", "exportPDF"] + ); mockFormatter = jasmine.createSpyObj( "formatter", ["formatDomainValue", "formatRangeValue"] @@ -107,6 +116,8 @@ define( controller = new PlotController( mockScope, + mockElement, + mockExportImageService, mockFormatter, mockHandler, mockThrottle diff --git a/platform/features/plot/test/services/ExportImageServiceSpec.js b/platform/features/plot/test/services/ExportImageServiceSpec.js new file mode 100644 index 00000000000..111a8d3432e --- /dev/null +++ b/platform/features/plot/test/services/ExportImageServiceSpec.js @@ -0,0 +1,141 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2016, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/** + * ExportImageServiceSpec. Created by hudsonfoo on 09/03/16. + */ +define( + ["../../src/services/ExportImageService"], + function (ExportImageService) { + var mockQ, + mockDeferred, + mockPromise, + mockTimeout, + mockLog, + mockHtml2Canvas, + mockCanvas, + mockJsPDF, + mockJsPDFSave, + mockSaveAs, + mockFileReader, + mockExportTimeoutConstant, + testElement, + exportImageService; + + describe("ExportImageService", function () { + beforeEach(function () { + mockDeferred = jasmine.createSpyObj( + "deferred", + ["reject", "resolve"] + ); + mockPromise = jasmine.createSpyObj( + "promise", + ["then", "finally"] + ); + mockPromise.then = function (callback) { + callback(); + }; + mockQ = { + "defer": function () { + return { + "resolve": mockDeferred.resolve, + "reject": mockDeferred.reject, + "promise": mockPromise + }; + } + }; + mockTimeout = function (fn, time) { + return { + "cancel": function () {} + }; + }; + mockLog = jasmine.createSpyObj( + "$log", + ["warn"] + ); + mockHtml2Canvas = jasmine.createSpy("html2canvas").andCallFake(function (element, opts) { + opts.onrendered(mockCanvas); + }); + mockCanvas = jasmine.createSpyObj( + "canvas", + ["toBlob"] + ); + mockJsPDFSave = jasmine.createSpy("jsPDFSave"); + mockJsPDF = function () { + return { + "addImage": function () {}, + "save": mockJsPDFSave + }; + }; + mockSaveAs = jasmine.createSpy("saveAs"); + mockFileReader = jasmine.createSpyObj( + "FileReader", + ["readAsDataURL", "onloadend"] + ); + mockExportTimeoutConstant = 0; + testElement = {}; + + exportImageService = new ExportImageService( + mockQ, + mockTimeout, + mockLog, + mockExportTimeoutConstant, + mockHtml2Canvas, + mockJsPDF, + mockSaveAs, + mockFileReader + ); + }); + + it("runs html2canvas and tries to save a pdf", function () { + exportImageService.exportPDF(testElement, "plot.pdf"); + mockFileReader.onloadend(); + + expect(mockHtml2Canvas).toHaveBeenCalledWith(testElement, { onrendered: jasmine.any(Function) }); + expect(mockCanvas.toBlob).toHaveBeenCalledWith(mockDeferred.resolve, "image/jpeg"); + expect(mockDeferred.reject).not.toHaveBeenCalled(); + expect(mockJsPDFSave).toHaveBeenCalled(); + expect(mockPromise.finally).toHaveBeenCalled(); + }); + + it("runs html2canvas and tries to save a png", function () { + exportImageService.exportPNG(testElement, "plot.png"); + + expect(mockHtml2Canvas).toHaveBeenCalledWith(testElement, { onrendered: jasmine.any(Function) }); + expect(mockCanvas.toBlob).toHaveBeenCalledWith(mockDeferred.resolve, "image/png"); + expect(mockDeferred.reject).not.toHaveBeenCalled(); + expect(mockSaveAs).toHaveBeenCalled(); + expect(mockPromise.finally).toHaveBeenCalled(); + }); + + it("runs html2canvas and tries to save a jpg", function () { + exportImageService.exportJPG(testElement, "plot.png"); + + expect(mockHtml2Canvas).toHaveBeenCalledWith(testElement, { onrendered: jasmine.any(Function) }); + expect(mockCanvas.toBlob).toHaveBeenCalledWith(mockDeferred.resolve, "image/jpeg"); + expect(mockDeferred.reject).not.toHaveBeenCalled(); + expect(mockSaveAs).toHaveBeenCalled(); + expect(mockPromise.finally).toHaveBeenCalled(); + }); + }); + } +); diff --git a/test-main.js b/test-main.js index c329407f931..0e28eb56619 100644 --- a/test-main.js +++ b/test-main.js @@ -54,6 +54,8 @@ requirejs.config({ "angular-route": "bower_components/angular-route/angular-route.min", "csv": "bower_components/comma-separated-values/csv.min", "es6-promise": "bower_components/es6-promise/promise.min", + "html2canvas": "bower_components/html2canvas/build/html2canvas.min", + "jsPDF": "bower_components/jspdf/dist/jspdf.min", "moment": "bower_components/moment/moment", "moment-duration-format": "bower_components/moment-duration-format/lib/moment-duration-format", "saveAs": "bower_components/FileSaver.js/FileSaver.min",