diff --git a/CHANGELOG.md b/CHANGELOG.md index b06f3c9..662bc41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * JSON schema powered form ([#27](https://github.com/NLESC-JCER/cpp2wasm/issues/27)) * WebAssembly module ([#35](https://github.com/NLESC-JCER/cpp2wasm/issues/35)) +* Vega plot ([#15](https://github.com/NLESC-JCER/cpp2wasm/issues/15)) ## [0.1.0] - 2020-06-04 diff --git a/README.md b/README.md index e36e2f5..e040f16 100644 --- a/README.md +++ b/README.md @@ -766,11 +766,14 @@ To be able to use the `createModule` function, we will import the `newtonraphson ```{.html file=src/js/example.html} - - - + + + Example + + + ``` @@ -878,10 +881,13 @@ Like before we need a HTML page to run the JavaScript, but now we don't need to ```{.html file=src/js/example-web-worker.html} - - + + + Example web worker + + ``` @@ -920,8 +926,11 @@ we implement the React application in the `app.js` file. ```{.html file=src/js/example-app.html} - - <> + + + Example React application + <> +
@@ -1177,8 +1186,11 @@ To render the application we need a HTML page. We will reuse the imports we did ```{.html file=src/js/example-jsonschema-form.html} - - <> + + + Example JSON schema powered form + <> +
@@ -1192,7 +1204,7 @@ To use the [react-jsonschema-form](https://github.com/rjsf-team/react-jsonschema ``` -The form component is exported as `JSONSchemaForm.default` and can be aliases to `Form` with +The form component is exported as `JSONSchemaForm.default` and can be aliases to `Form` for easy use with ```{.js #jsonschema-app} // this JavaScript snippet is appended to <> @@ -1249,12 +1261,12 @@ The `handleSubmit` function recieves the form input values and use the web worke // this JavaScript snippet is appended to <> const [root, setRoot] = React.useState(undefined); -function handleSubmit({formData}, event) { +function handleSubmit(submission, event) { event.preventDefault(); const worker = new Worker('worker.js'); worker.postMessage({ type: 'CALCULATE', - payload: formData + payload: submission.formData }); worker.onmessage = function(message) { if (message.data.type === 'RESULT') { @@ -1311,4 +1323,291 @@ If you enter a negative number in the `epsilon` field the form will become inval ### Visualization -The plots in web apllicatoin can be made using [vega-lite](https://vega.github.io/vega-lite/). Vega-lite is a JS library which accepts a JSON document describing the plot and generates interactive graphics. +The plots in web application can be made using [vega-lite](https://vega.github.io/vega-lite/). Vega-lite is a JS library which accepts a JSON document describing the plot. + +To make an interesting plot we need more than one result. We are going to do a parameter sweep and measure how long each calculation takes. + +Lets make a new JSON schema for the form in which we can set a max, min and step for epsilon. + +```{.js #plot-app} +// this JavaScript snippet is later referred to as <> +const schema = { + "type": "object", + "properties": { + "epsilon": { + "title": "Epsilon", + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0, + "default": 0.0001 + }, + "max": { + "type": "number", + "minimum": 0, + "default": 0.001 + }, + "step": { + "type": "number", + "minimum": 0, + "default": 0.0001 + } + }, + "required": ["min", "max", "step"], + "additionalProperties": false + }, + "guess": { + "title": "Initial guess", + "type": "number", + "minimum": -100, + "maximum": 100, + "default": -20 + } + }, + "required": ["epsilon", "guess"], + "additionalProperties": false +} +``` + +We need to rewrite the worker to perform a parameter sweep. +The worker will recieve a payload like + +```json +{ + "epsilon": { + "min": 0.0001, + "max": 0.001, + "step": 0.0001 + }, + "guess": -20 +} +``` + +The worker will send back an array containing objects with the root result, the input parameters and the duration in milliseconds. + +```json +[{ + "epsilon": 0.0001, + "guess": -20, + "root": -1, + "duration": 0.61 +}] +``` + +To perform the sweep we will first unpack the payload. + +```{.js #calculate-sweep} +// this JavaScript snippet is later referred to as <> +const {min, max, step} = message.data.payload.epsilon; +const guess = message.data.payload.guess; +``` + +The result array needs to be initialized. + +```{.js #calculate-sweep} +// this JavaScript snippet appended to <> +const roots = []; +``` + +Lets use a [classic for loop](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for) to iterate over requested the epsilons. + +```{.js #calculate-sweep} +// this JavaScript snippet appended to <> +for (let epsilon = min; epsilon <= max; epsilon += step) { +``` + +To measure the duration of a calculation we use the [performance.now()](https://developer.mozilla.org/en-US/docs/Web/API/Performance/now) method which returns a timestamp in milliseconds. + +```{.js #calculate-sweep} + // this JavaScript snippet appended to <> + const t0 = performance.now(); + const finder = new module.NewtonRaphson(epsilon); + const root = finder.find(guess); + const duration = performance.now() - t0; +``` + +We append the root result object using [shorthand property names](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer) to the result array. + +```{.js #calculate-sweep} + // this JavaScript snippet appended to <> + roots.push({ + epsilon, + guess, + root, + duration + }); +``` + +To complete the sweep calculation we need to close the for loop and post the result. + +```{.js #calculate-sweep} + // this JavaScript snippet appended to <> +} +postMessage({ + type: 'RESULT', + payload: { + roots + } +}); +``` + +The sweep calculation snippet (`<>`) must be run in a new web worker called `worker-sweep.js`. +Like before we need to wait for the WebAssembly module to be initialized before we can start the calculation. + +```{.js file=src/js/worker-sweep.js} +// this JavaScript snippet stored as src/js/worker-sweep.js +importScripts('newtonraphsonwasm.js'); + +onmessage = function(message) { + if (message.data.type === 'CALCULATE') { + createModule().then((module) => { + <> + }); + } +}; +``` + +To handle the submit we will start a worker, send the form data to the worker, recieve the workers result and store it in the `roots` variable. + +```{.js #plot-app} +// this JavaScript snippet is appended to <> +const [roots, setRoots] = React.useState([]); + +function handleSubmit(submission, event) { + event.preventDefault(); + const worker = new Worker('worker-sweep.js'); + worker.postMessage({ + type: 'CALCULATE', + payload: submission.formData + }); + worker.onmessage = function(message) { + if (message.data.type === 'RESULT') { + const result = message.data.payload.roots; + setRoots(result); + worker.terminate(); + } + }; +} +``` + +Now that we got data, we are ready to plot. We use the + [Vega-Lite specification](https://vega.github.io/vega-lite/docs/spec.html) to declare the plot. +The specification for a scatter plot of the `epsilon` against the `duration` looks like. + +```{.js #vega-lite-spec} +// this JavaScript snippet is later referred to as <> +const spec = { + "$schema": "https://vega.github.io/schema/vega-lite/v4.json", + "data": { "values": roots }, + "mark": "point", + "encoding": { + "x": { "field": "epsilon", "type": "quantitative" }, + "y": { "field": "duration", "type": "quantitative", "title": "Duration (ms)" } + }, + "width": 800, + "height": 600 +}; +``` + +To render the spec we use the [vegaEmbed](https://github.com/vega/vega-embed) module. The Vega-Lite specification is a simplification of the [Vega specification](https://vega.github.io/vega/docs/specification/) so wil first import `vega` then `vega-lite` and lastly `vega-embed`. + +```{.html #imports} + + + +``` + +The `vegaEmbed()` function needs a DOM element to render the plot in. +In React we must use the [useRef](https://reactjs.org/docs/hooks-reference.html#useref) hook to get a reference to a DOM element. As the DOM element needs time to initialize we need to use the [useEffect](https://reactjs.org/docs/hooks-effect.html) hook to only embed the plot when the DOM element is ready. The `Plot` React component can be written as + +```{.jsx #plot-component} +// this JavaScript snippet is later referred to as <> +function Plot({roots}) { + const container = React.useRef(null); + + function didUpdate() { + if (container.current === null) { + return; + } + <> + vegaEmbed(container.current, spec); + } + const dependencies = [container, roots]; + React.useEffect(didUpdate, dependencies); + + return
; +} +``` + +The App component can be defined and rendered with. + +```{.jsx file=src/js/plot-app.js} +// this JavaScript snippet stored as src/js/plot-app.js +<> + +<> + +function App() { + const Form = JSONSchemaForm.default; + const uiSchema = { + "guess": { + "ui:widget": "range" + } + } + const [formData, setFormData] = React.useState({ + + }); + + function handleChange(event) { + setFormData(event.formData); + } + + <> + + return ( +
+ + <> + +
+ ); +} + +ReactDOM.render( + , + document.getElementById('container') +); +``` + +The html page should look like + +```{.html file=src/js/example-plot.html} + + + + + Example plot + <> + + +
+ + + + +``` + +Like before we also need to host the files in a web server with + +```shell +python3 -m http.server 8000 +``` + +Visit [http://localhost:8000/src/js/example-plot.html](http://localhost:8000/src/js/example-plot.html) to see the epsilon/duration plot. + +Embedded below is the example app hosted on [GitHub pages](https://nlesc-jcer.github.io/cpp2wasm/src/js/example-plot.html) + + + +After the submit button is pressed the plot should show that the first calculation took a bit longer then the rest. diff --git a/TESTING.md b/TESTING.md index bff8b23..3177a48 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2,12 +2,18 @@ To make sure [JavaScript and WebAssembly code snippets](README.md#JavaScript) and [Single page application](README.md#single-page-application) work we want have a tests for them. -To test, we will use the [cypress](https://www.cypress.io/) JavaScript end to end testing framework. +To test, we will use the [cypress](https://www.cypress.io/) JavaScript end to end testing framework. Cypress can simulate user behavior such as clicking buttons etc. and checks expected result in a web browser. -In the following example, we test if the example web page renders the answer `-1.00` when it is visited. +In the following examples, we test if the example web pages render the answer `-1.00` when they are visited. -Let's, first write a test for the direct WebAssembly example. +To visit a web page we need to start a simple web server with using Python + +```shell +python3 -m http.server 8000 +``` + +Let's, first write a test for the [direct WebAssembly example](http://localhost:8000/src/js/example.html). ```{.js file=cypress/integration/example_spec.js} // this JavaScript snippet is run by cypress and is stored as cypress/integration/example_spec.js @@ -19,7 +25,7 @@ describe('src/js/example.html', () => { }); ``` -Second, a test for the WebAssembly called through a web worker. +Second, a test for the WebAssembly called through a [web worker](http://localhost:8000/src/js/example-web-worker.html). ```{.js file=cypress/integration/example-web-worker_spec.js} // this JavaScript snippet is run by cypress and is stored as cypress/integration/example-web-worker_spec.js @@ -31,35 +37,50 @@ describe('src/js/example-web-worker.html', () => { }); ``` -And lastly, a test for the React/form/Web worker/WebAssembly combination. -Let us also change the guess value. +Third, a test for the [React/form/Web worker/WebAssembly combination](http://localhost:8000/src/js/example-app.html). +Let us also change the initial guess value. ```{.js file=cypress/integration/example-app_spec.js} describe('src/js/example-app.html', () => { it('should render -1.00', () => { cy.visit('http://localhost:8000/src/js/example-app.html'); + // The initial value of the guess input field is -20 so we append a 0 and it becomes -200 cy.get('input[name=guess]').type('0'); - // TODO assert value is set cy.contains('Submit').click(); cy.get('#answer').contains('-1.00'); }); }); ``` -And another test for the full application, but now with JSON schema powered form. +And similar test to the previous one, but now with [JSON schema powered form](http://localhost:8000/src/js/example-jsonschema-form.html). ```{.js file=cypress/integration/example-jsonschema-form_spec.js} describe('src/js/example-jsonschema-form.html', () => { it('should render -1.00', () => { cy.visit('http://localhost:8000/src/js/example-jsonschema-form.html'); - cy.get('input[id=root_epsilon]').type('{selectall}0.1'); - // TODO assert value is set + // The JSON schema powered form uses a hierarchy of identifiers for each input field starting with `root` + // As the `epsilon` input field is a direct child of root, it has `root_epsilon` as an identifier + const input_selector = 'input[id=root_epsilon]'; + // In initial guess input field we replace the default value with 0.1 + cy.get(input_selector).type('{selectall}0.1'); cy.contains('Submit').click(); cy.get('#answer').contains('-1.00'); }); }); ``` +And lastly a test for the [web application with a plot](http://localhost:8000/src/js/example-plot.html). + +```{.js file=cypress/integration/example-plot_spec.js} +describe('src/js/example-plot.html', () => { + it('should render -1.00', () => { + cy.visit('http://localhost:8000/src/js/example-plot.html'); + cy.contains('Submit').click(); + // TODO assert plot has been plotted, see https://github.com/NLESC-JCER/cpp2wasm/issues/55 + }); +}); +``` + The test can be run with the following command: ```{.awk #test-wasm} diff --git a/cypress/integration/example-app_spec.js b/cypress/integration/example-app_spec.js index 65ea69e..d52c07c 100644 --- a/cypress/integration/example-app_spec.js +++ b/cypress/integration/example-app_spec.js @@ -1,8 +1,8 @@ describe('src/js/example-app.html', () => { it('should render -1.00', () => { cy.visit('http://localhost:8000/src/js/example-app.html'); + // The initial value of the guess input field is -20 so we append a 0 and it becomes -200 cy.get('input[name=guess]').type('0'); - // TODO assert value is set cy.contains('Submit').click(); cy.get('#answer').contains('-1.00'); }); diff --git a/cypress/integration/example-jsonschema-form_spec.js b/cypress/integration/example-jsonschema-form_spec.js index ead379d..80c7209 100644 --- a/cypress/integration/example-jsonschema-form_spec.js +++ b/cypress/integration/example-jsonschema-form_spec.js @@ -1,8 +1,11 @@ describe('src/js/example-jsonschema-form.html', () => { it('should render -1.00', () => { cy.visit('http://localhost:8000/src/js/example-jsonschema-form.html'); - cy.get('input[id=root_epsilon]').type('{selectall}0.1'); - // TODO assert value is set + // The JSON schema powered form uses a hierarchy of identifiers for each input field starting with `root` + // As the `epsilon` input field is a direct child of root, it has `root_epsilon` as an identifier + const input_selector = 'input[id=root_epsilon]'; + // In initial guess input field we replace the default value with 0.1 + cy.get(input_selector).type('{selectall}0.1'); cy.contains('Submit').click(); cy.get('#answer').contains('-1.00'); }); diff --git a/cypress/integration/example-plot_spec.js b/cypress/integration/example-plot_spec.js new file mode 100644 index 0000000..384d166 --- /dev/null +++ b/cypress/integration/example-plot_spec.js @@ -0,0 +1,7 @@ +describe('src/js/example-plot.html', () => { + it('should render -1.00', () => { + cy.visit('http://localhost:8000/src/js/example-plot.html'); + cy.contains('Submit').click(); + // TODO assert plot has been plotted, see https://github.com/NLESC-JCER/cpp2wasm/issues/55 + }); +}); \ No newline at end of file diff --git a/src/js/example-app.html b/src/js/example-app.html index fab26d9..f9d45f7 100644 --- a/src/js/example-app.html +++ b/src/js/example-app.html @@ -1,15 +1,21 @@ - - - - - - - - - - + + + Example React application + + + + + + + + + + + + +
diff --git a/src/js/example-jsonschema-form.html b/src/js/example-jsonschema-form.html index d3fc452..f9780ac 100644 --- a/src/js/example-jsonschema-form.html +++ b/src/js/example-jsonschema-form.html @@ -1,15 +1,21 @@ - - - - - - - - - - + + + Example JSON schema powered form + + + + + + + + + + + + +
diff --git a/src/js/example-plot.html b/src/js/example-plot.html new file mode 100644 index 0000000..a31f9c0 --- /dev/null +++ b/src/js/example-plot.html @@ -0,0 +1,24 @@ + + + + + Example plot + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/src/js/example-web-worker.html b/src/js/example-web-worker.html index 170077a..638a1e6 100644 --- a/src/js/example-web-worker.html +++ b/src/js/example-web-worker.html @@ -1,23 +1,26 @@ - - + + \ No newline at end of file diff --git a/src/js/example.html b/src/js/example.html index 3ec769c..571503a 100644 --- a/src/js/example.html +++ b/src/js/example.html @@ -1,19 +1,22 @@ - - - + + + Example + + + \ No newline at end of file diff --git a/src/js/jsonschema-app.js b/src/js/jsonschema-app.js index 0237341..e625ba5 100644 --- a/src/js/jsonschema-app.js +++ b/src/js/jsonschema-app.js @@ -40,12 +40,12 @@ function App() { // this JavaScript snippet is appended to <> const [root, setRoot] = React.useState(undefined); - function handleSubmit({formData}, event) { + function handleSubmit(submission, event) { event.preventDefault(); const worker = new Worker('worker.js'); worker.postMessage({ type: 'CALCULATE', - payload: formData + payload: submission.formData }); worker.onmessage = function(message) { if (message.data.type === 'RESULT') { diff --git a/src/js/plot-app.js b/src/js/plot-app.js new file mode 100644 index 0000000..2916ae0 --- /dev/null +++ b/src/js/plot-app.js @@ -0,0 +1,127 @@ +// this JavaScript snippet stored as src/js/plot-app.js +// this JavaScript snippet is later referred to as <> +function Heading() { + const title = 'Root finding web application'; + return

{title}

+} + +// this JavaScript snippet is later referred to as <> +function Plot({roots}) { + const container = React.useRef(null); + + function didUpdate() { + if (container.current === null) { + return; + } + // this JavaScript snippet is later referred to as <> + const spec = { + "$schema": "https://vega.github.io/schema/vega-lite/v4.json", + "data": { "values": roots }, + "mark": "point", + "encoding": { + "x": { "field": "epsilon", "type": "quantitative" }, + "y": { "field": "duration", "type": "quantitative", "title": "Duration (ms)" } + }, + "width": 800, + "height": 600 + }; + vegaEmbed(container.current, spec); + } + const dependencies = [container, roots]; + React.useEffect(didUpdate, dependencies); + + return
; +} + +function App() { + const Form = JSONSchemaForm.default; + const uiSchema = { + "guess": { + "ui:widget": "range" + } + } + const [formData, setFormData] = React.useState({ + + }); + + function handleChange(event) { + setFormData(event.formData); + } + + // this JavaScript snippet is later referred to as <> + const schema = { + "type": "object", + "properties": { + "epsilon": { + "title": "Epsilon", + "type": "object", + "properties": { + "min": { + "type": "number", + "minimum": 0, + "default": 0.0001 + }, + "max": { + "type": "number", + "minimum": 0, + "default": 0.001 + }, + "step": { + "type": "number", + "minimum": 0, + "default": 0.0001 + } + }, + "required": ["min", "max", "step"], + "additionalProperties": false + }, + "guess": { + "title": "Initial guess", + "type": "number", + "minimum": -100, + "maximum": 100, + "default": -20 + } + }, + "required": ["epsilon", "guess"], + "additionalProperties": false + } + // this JavaScript snippet is appended to <> + const [roots, setRoots] = React.useState([]); + + function handleSubmit(submission, event) { + event.preventDefault(); + const worker = new Worker('worker-sweep.js'); + worker.postMessage({ + type: 'CALCULATE', + payload: submission.formData + }); + worker.onmessage = function(message) { + if (message.data.type === 'RESULT') { + const result = message.data.payload.roots; + setRoots(result); + worker.terminate(); + } + }; + } + + return ( +
+ + { /* this JavaScript snippet is later referred to as <> */} +
+ +
+ ); +} + +ReactDOM.render( + , + document.getElementById('container') +); \ No newline at end of file diff --git a/src/js/worker-sweep.js b/src/js/worker-sweep.js new file mode 100644 index 0000000..808205f --- /dev/null +++ b/src/js/worker-sweep.js @@ -0,0 +1,36 @@ +// this JavaScript snippet stored as src/js/worker-sweep.js +importScripts('newtonraphsonwasm.js'); + +onmessage = function(message) { + if (message.data.type === 'CALCULATE') { + createModule().then((module) => { + // this JavaScript snippet is later referred to as <> + const {min, max, step} = message.data.payload.epsilon; + const guess = message.data.payload.guess; + // this JavaScript snippet appended to <> + const roots = []; + // this JavaScript snippet appended to <> + for (let epsilon = min; epsilon <= max; epsilon += step) { + // this JavaScript snippet appended to <> + const t0 = performance.now(); + const finder = new module.NewtonRaphson(epsilon); + const root = finder.find(guess); + const duration = performance.now() - t0; + // this JavaScript snippet appended to <> + roots.push({ + epsilon, + guess, + root, + duration + }); + // this JavaScript snippet appended to <> + } + postMessage({ + type: 'RESULT', + payload: { + roots + } + }); + }); + } +}; \ No newline at end of file