From 9e7e401a7cc4239fcf0194f6dab0b541c2adab6c Mon Sep 17 00:00:00 2001 From: Carlos Downie <42552189+downiec@users.noreply.github.com> Date: Wed, 14 Nov 2018 16:04:57 -0800 Subject: [PATCH] Quick updates (#331) * Removed 'required' from hasError and handleError proptypes to prevent error, updated setup.sh to install python2, fixed an error about unreachable code in Colormaps.js * Testing save_screenshot function. * Adding save plot functionality. * Testing screenshot function * Added save plot functionailty and connections. * Added save plot functionality fully implemented. Modified export modal to allow user to specify screenshot dimensions as well as export type. * Minor cleanup of code. Removed tabs from export modal since they aren't needed. Removed corresponding test for tab values from ExportModalTest.jsx * Updated README file so that users will see link to user documentation only. Removed comment about being early development phase. * Updated README file text. * Updated README.md * Updated plot export function so that it works with different settings and multiple layers. Minor changes to some help-tutorial text. * Added data mock data store and few test cases for ExportModal.jsx test. Tests passing. --- README.md | 4 +- frontend/src/js/components/Canvas.jsx | 2 +- .../src/js/components/modals/ExportModal.jsx | 31 ++- .../components/modals/SavePlot/SavePlot.jsx | 219 ++++++++++++++---- frontend/src/js/constants/Constants.js | 4 +- .../components/Modals/ExportModalTest.jsx | 117 +++++++++- 6 files changed, 320 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index f6c58f1..6a68d40 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,7 @@ [![Build Status](https://travis-ci.org/CDAT/vcdat.svg?branch=master)](https://travis-ci.org/CDAT/vcdat) [![Coverage Status](https://coveralls.io/repos/github/CDAT/vcdat/badge.svg?branch=master)](https://coveralls.io/github/CDAT/vcdat?branch=master) -#### New to vCDAT? Check out the documentation for [Users](https://cdat.github.io/vcdat/docs/html/user_install.html) and [Developers](https://cdat.github.io/vcdat/docs/html/dev_install.html) - -_This project is in the early stages of development. As such please be aware that there may be some bugs and not all features will be available._ +#### New to vCDAT? Check out the [Documentation](https://cdat.github.io/vcdat/docs/html/user_install.html). vCDAT is a desktop application that provides the graphical frontend for the CDAT package. It uses CDAT's VCS and CDMS modules to render high quality visualizations within a browser. diff --git a/frontend/src/js/components/Canvas.jsx b/frontend/src/js/components/Canvas.jsx index 0de7509..823df0b 100644 --- a/frontend/src/js/components/Canvas.jsx +++ b/frontend/src/js/components/Canvas.jsx @@ -111,7 +111,7 @@ class Canvas extends Component { } return dataSpec; }); - console.log("plotting", dataSpecs, this.props.plotGMs[index], plot.template); + console.log("Plotting",index, dataSpecs, this.props.plotGMs[index], plot.template); return this.canvas.plot(dataSpecs, this.props.plotGMs[index], plot.template).then( success => { return; diff --git a/frontend/src/js/components/modals/ExportModal.jsx b/frontend/src/js/components/modals/ExportModal.jsx index 77476c3..0f812a9 100644 --- a/frontend/src/js/components/modals/ExportModal.jsx +++ b/frontend/src/js/components/modals/ExportModal.jsx @@ -1,6 +1,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import { Modal, Button } from 'react-bootstrap' +import { connect } from 'react-redux' import SavePlot from './SavePlot/SavePlot.jsx' class ExportModal extends Component { @@ -23,6 +24,7 @@ class ExportModal extends Component { this.handleDimensionUpdate = this.handleDimensionUpdate.bind(this); this.handleDimensionChange = this.handleDimensionChange.bind(this); this.handleDimensionExit = this.handleDimensionExit.bind(this); + this.plots = null; } handleChangeExt(type){ @@ -54,6 +56,11 @@ class ExportModal extends Component { } render(){ + + if(this.props.cells && this.props.row >= 0 && this.props.col >= 0){ + this.plots = this.props.cells[this.props.row][this.props.col].plots; + } + return( @@ -126,6 +133,7 @@ class ExportModal extends Component { exportType={this.state.exportType} handleChangeExt={this.handleChangeExt} handleDimensionUpdate={this.handleDimensionUpdate} + plots={this.plots} /> @@ -136,10 +144,29 @@ class ExportModal extends Component { } } +const mapStateToProps = (state) => { + // Prepare parameters to pass to save plot component + // format of `sheet_row_col`. Ex: "0_0_0" + let sheet_row_col = state.present.sheets_model.selected_cell_id.split("_").map( + function (str_val) { return Number(str_val) } + ); + let row = sheet_row_col[1]; + let col = sheet_row_col[2]; + return { + cells: state.present.sheets_model.sheets[state.present.sheets_model.cur_sheet_index].cells, + row: row, + col: col + } +} + ExportModal.propTypes = { - selected_cell_id: PropTypes.string, show: PropTypes.bool.isRequired, close: PropTypes.func.isRequired, + // Added for getting plot information + sheet_row_col: PropTypes.array, + cells: PropTypes.any, + row: PropTypes.number, + col: PropTypes.number } -export default ExportModal +export default connect(mapStateToProps, null)(ExportModal) diff --git a/frontend/src/js/components/modals/SavePlot/SavePlot.jsx b/frontend/src/js/components/modals/SavePlot/SavePlot.jsx index 15d14f9..7e1043c 100644 --- a/frontend/src/js/components/modals/SavePlot/SavePlot.jsx +++ b/frontend/src/js/components/modals/SavePlot/SavePlot.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types' import { connect } from 'react-redux' import { toast } from 'react-toastify' import FileSaver from 'file-saver' +import _ from "lodash"; import './SavePlot.scss' class SavePlot extends Component{ @@ -13,9 +14,15 @@ class SavePlot extends Component{ img_url: "", } this.savePlot = this.savePlot.bind(this); - this.canvasDiv = null; + this.plotAll = this.plotAll.bind(this); + this.prepareCanvas = this.prepareCanvas.bind(this); this.exportDimensions = this.props.exportDimensions; this.exportType = this.props.exportType; + this.canvasDiv = null; + this.canvas = null; + this.error = false; + this.ready = false; // Whether canvas is ready for export + this.notify = false; // If user clicked save before plots were finished, notify when ready. } /* istanbul ignore next */ @@ -24,20 +31,139 @@ class SavePlot extends Component{ let elements = document.querySelectorAll(`.cell-stack-top > #canvas_${this.props.selected_cell_id} > canvas`); if(elements && elements.length > 0){ this.canvasDiv = elements[0]; - + // Update default dimensions to match current window size this.props.handleDimensionUpdate([this.canvasDiv.width,this.canvasDiv.height]); + // Create plot preview thumbnail this.canvasDiv.toBlob((blob) => { this.setState({img_url: URL.createObjectURL(blob)}) }); + + // Initialize canvas object + this.canvas = vcs.init(this.canvasDiv); + + this.prepareCanvas(); + } + } + + prepareCanvas() { + // Plot on the canvas for future saving and set ready flag when done + this.plotAll().then( + success=>{ + this.ready=true; + this.error = false; + if(this.notify) { + this.notify = false; + toast.success("Export ready!", {position: toast.POSITION.BOTTOM_CENTER}); + }}, + error=>{ + toast.warn("Plotting error occurred.", {position: toast.POSITION.BOTTOM_CENTER}); + this.error = true; + console.log("Plotting error: ",error); + + } + ); + } + + // Plots on the canvas all the layers + async plotAll() { + + if(this.props.plots){ + for (let [index, plot] of this.props.plots.entries()) { + await this.plot(plot, index); + } + console.log("Plots finished!"); + } + else{ + console.log("Plots weren't loaded."); + } + } + + // This was directly taken from Canvas.jsx + plot(plot, index) { + if (plot.variables.length > 0) { + var variables = this.props.plotVariables[index]; + var dataSpecs = variables.map(function(variable) { + var dataSpec; + if (variable.json) { + dataSpec = { + uri: variable.path, + variable: variable.cdms_var_name, + json: variable.json + }; + } else { + dataSpec = { + uri: variable.path, + variable: variable.cdms_var_name + }; + } + + var subRegion = {}; + variable.dimension.filter(dimension => dimension.values).forEach(dimension => { + subRegion[dimension.axisName] = dimension.values.range; + }); + if (!_.isEmpty(subRegion)) { + dataSpec["operations"] = [{ subRegion }]; + } + if (!_.isEmpty(variable.transforms)) { + if (!dataSpec["operations"]) { + dataSpec["operations"] = []; + } + dataSpec["operations"].push({ transform: variable.transforms }); + } + var axis_order = variable.dimension.map(dimension => variable.axisList.indexOf(dimension.axisName)); + if (axis_order.some((order, index) => order !== index)) { + dataSpec["axis_order"] = axis_order; + } + return dataSpec; + }); + console.log("Plot " + index + " for export.", dataSpecs, this.props.plotGMs[index], plot.template); + return this.canvas.plot(dataSpecs, this.props.plotGMs[index], plot.template).then( + success => { + console.log("Plot " + index + " complete."); + return; + }, + error => { + this.canvas.close(); + this.error = true; + delete this.canvas; + this.canvas = vcs.init(this.div); + if (error.data) { + console.warn("Error while creating save plot: ", error); + toast.error(error.data.exception, { position: toast.POSITION.BOTTOM_CENTER }); + } else { + console.warn("Unknown error while saving plot: ", error); + toast.error("Error while saving plot.", { position: toast.POSITION.BOTTOM_CENTER }); + } + } + ); } } + /* istanbul ignore next */ savePlot(){ if(this.canvasDiv){ + if(!this.canvas) { + console.log("Canvas object is empty."); + return; + } + + // Cancel export if canvas plots aren't ready + if(!this.ready) { + + if(this.error){ + // If an error had occurred, try plotting again + this.prepareCanvas(); + } + + this.notify = true; // Set notify flag, so user will be notified when plot is ready to save + toast.warn("Performing plotting operation again. Will notify when ready to save.", {position: toast.POSITION.BOTTOM_CENTER}); + return; + } + // Validate screenshot name let fileName = this.state.name; @@ -75,45 +201,23 @@ class SavePlot extends Component{ return; } - // Prepare parameters - // format of `sheet_row_col`. Ex: "0_0_0" - let sheet_row_col = this.props.selected_cell_id.split("_").map(function (str_val) { return Number(str_val) }); - let sheet = sheet_row_col[0]; - let row = sheet_row_col[1]; - let col = sheet_row_col[2]; - - // Get info about the plot from redux store props - let plotInfo = this.props.sheets_model.sheets[sheet].cells[row][col].plots[0]; - - let variable = { - uri: this.props.variables[plotInfo.variables[0]].path, - variable: this.props.variables[plotInfo.variables[0]].cdms_var_name, - }; - - let graphicMethod = this.props.graphics[plotInfo.graphics_method_parent][plotInfo.graphics_method]; - - // Initialize canvas object and plot - let canvas = vcs.init(this.canvasDiv); - - canvas.plot(variable, graphicMethod, plotInfo.template).then((info) => { - canvas.screenshot(ext, true, false, fileName, this.props.exportDimensions[0], this.props.exportDimensions[1]).then((result, msg) => { + // Create screenshot and save + this.canvas.screenshot(ext, true, false, fileName, this.props.exportDimensions[0], this.props.exportDimensions[1]).then((result, msg) => { + if(msg){ console.log(msg); - if(result.success){ - const { blob, type } = result; - console.log(type + " file was saved."); - FileSaver.saveAs(blob, this.state.name); - toast.success("Plot saved!", {position: toast.POSITION.BOTTOM_CENTER}); - this.setState({name:""}); - } else { - console.log(result.msg); - } - }).catch((err) => { - console.log(err); - toast.error("Error occurred when saving plot.", {position: toast.POSITION.BOTTOM_CENTER}); - }); + } + + if(result.success){ + const { blob, type } = result; + FileSaver.saveAs(blob, this.state.name); + toast.success("Plot saved!", {position: toast.POSITION.BOTTOM_CENTER}); + this.setState({name:""}); + } else { + console.log(result.msg); + } }).catch((err) => { console.log(err); - toast.error("Error occurred when plotting.", {position: toast.POSITION.BOTTOM_CENTER}); + toast.error("Error occurred when saving plot.", {position: toast.POSITION.BOTTOM_CENTER}); }); } else{ @@ -149,13 +253,32 @@ class SavePlot extends Component{ } } -const mapStateToProps = (state) => { +const mapStateToProps = (state, ownProps) => { + + // When GMs are loaded, use this function to extract them from the state + var get_gm_for_plot = plot => { + return state.present.graphics_methods[plot.graphics_method_parent][plot.graphics_method]; + }; + + var get_vars_for_plot = plot => { + return plot.variables.map(variable => { + return state.present.variables[variable]; + }); + }; - return { - selected_cell_id: state.present.sheets_model.selected_cell_id, - sheets_model: state.present.sheets_model, - variables: state.present.variables, - graphics: state.present.graphics_methods + if(ownProps.plots){ + return { + selected_cell_id: state.present.sheets_model.selected_cell_id, + plotVariables: ownProps.plots.map(get_vars_for_plot), + plotGMs: ownProps.plots.map(get_gm_for_plot) + } + } + else { + return { + selected_cell_id: state.present.sheets_model.selected_cell_id, + plotVariables: null, + plotGMs: null + } } } @@ -168,10 +291,10 @@ SavePlot.propTypes = { handleDimensionUpdate: PropTypes.func, handleChangeExt: PropTypes.func, exportType: PropTypes.string, + plots: PropTypes.any, + plotVariables: PropTypes.array, + plotGMs: PropTypes.array, onSave: PropTypes.func, - variables: PropTypes.any, - graphics: PropTypes.any, - sheets_model: PropTypes.any } -export default connect(mapStateToProps, null)(SavePlot) \ No newline at end of file +export default connect(mapStateToProps)(SavePlot); \ No newline at end of file diff --git a/frontend/src/js/constants/Constants.js b/frontend/src/js/constants/Constants.js index 9248f81..5234a3f 100644 --- a/frontend/src/js/constants/Constants.js +++ b/frontend/src/js/constants/Constants.js @@ -9,7 +9,7 @@ const JOYRIDE_STEPS = [ text: "The following tour will help guide you through the basic features of vCDAT. ".concat( 'Click "Next" to continue the tour. ', '' + 'Contact Us' ), selector: ".joyride", position: "top", @@ -107,7 +107,7 @@ const JOYRIDE_STEPS = [ '
Add Plot will add an additional plot to a cell. ', "Use this as an overlay or as an in-cell side-by-side comparison.", '
Clear Cell will reset the cell back to the default. ', - "This can be undone if you accidentally click it with the undo button.", + "This can be undone with the undo button, if you accidentally click the 'clear cell' button.", '
Colormap Editor will open a window for creating, editing, and applying colormaps.', '
Export allows you to export/save the plot.', '
Calculator allows you to derive new variables using', diff --git a/frontend/test/mocha/components/Modals/ExportModalTest.jsx b/frontend/test/mocha/components/Modals/ExportModalTest.jsx index 30cb4e5..f44e9e7 100644 --- a/frontend/test/mocha/components/Modals/ExportModalTest.jsx +++ b/frontend/test/mocha/components/Modals/ExportModalTest.jsx @@ -7,10 +7,16 @@ import ExportModal from "../../../../src/js/components/modals/ExportModal.jsx"; import Enzyme from "enzyme"; import Adapter from "enzyme-adapter-react-16"; Enzyme.configure({ adapter: new Adapter() }); +import { createMockStore } from 'redux-test-utils' import { shallow } from "enzyme"; const getProps = () => { return { + plots: [{ + graphics_method_parent: "boxfill", + graphics_method: "default", + variables: [] + }], show: true, close: () => {} }; @@ -18,8 +24,117 @@ const getProps = () => { describe("ExportModalTest.jsx", function() { it("renders without exploding", () => { + + var test_plot1 = { + variables: [], // testing inspector + graphics_method_parent: 'boxfill', + graphics_method: 'default', + template: 'default' + } + + var test_plot2 = { + variables: ['clt'], // testing inspector + graphics_method_parent: 'boxfill', + graphics_method: 'default', + template: 'default' + } + + var test_cell1 = { + plot_being_edited: 0, + plots: [test_plot1] + } + + var test_cell2 = { + plot_being_edited: 1, + plots: [test_plot1,test_plot2] + } + + const test_sheet1 = { + name: 'Sheet', + col_count: 1, + row_count: 1, + selected_cell_indices: [ + [-1, -1] + ], + cells: [ + [test_cell1] + ], + sheet_index: 0, + } + + const test_sheet2 = { + name: 'Sheet', + col_count: 1, + row_count: 1, + selected_cell_indices: [ + [0, 1] + ], + cells: [ + [test_cell1,test_cell2],[test_cell2,test_cell1] + ], + sheet_index: 0, + } + + const test_sheet3 = { + name: 'Sheet', + col_count: 1, + row_count: 1, + selected_cell_indices: [ + [1, 0] + ], + cells: [ + [test_cell1,test_cell2],[test_cell2,test_cell1] + ], + sheet_index: 0, + } + + const store_with_no_selected_cell = createMockStore({ + present: { + sheets_model:{ + selected_cell_id: "-1_-1_-1", + cur_sheet_index: 0, + sheets: [test_sheet1] + } + } + }) + const store_with_selected_cell = createMockStore({ + present: { + sheets_model:{ + selected_cell_id: "0_0_0", + cur_sheet_index: 0, + sheets: [test_sheet1,test_sheet2,test_sheet3] + } + } + }) + const store_with_selected_cell2 = createMockStore({ + present: { + sheets_model:{ + selected_cell_id: "0_0_0", + cur_sheet_index: 1, + sheets: [test_sheet1,test_sheet2,test_sheet3] + } + } + }) + const store_with_selected_cell3 = createMockStore({ + present: { + sheets_model:{ + selected_cell_id: "0_0_0", + cur_sheet_index: 2, + sheets: [test_sheet1,test_sheet2,test_sheet3] + } + } + }) const props = getProps(); - var export_modal = shallow(); + var export_modal = shallow(); + expect(export_modal).to.have.lengthOf(1); + + export_modal = shallow(); + expect(export_modal).to.have.lengthOf(1); + + export_modal = shallow(); + expect(export_modal).to.have.lengthOf(1); + + export_modal = shallow(); expect(export_modal).to.have.lengthOf(1); }); }); \ No newline at end of file