├── index.js ├── data-canvas-stack.gif ├── .travis.yml ├── .gitignore ├── test ├── runner.html ├── coverage.html └── data-canvas-test.js ├── package.json ├── generate-coverage.sh ├── flowtype └── types.js ├── README.md ├── LICENSE └── src └── data-canvas.js /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src/data-canvas'); 2 | -------------------------------------------------------------------------------- /data-canvas-stack.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hammerlab/data-canvas/HEAD/data-canvas-stack.gif -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false # Use container-based infrastructure 2 | language: node_js 3 | node_js: 4 | - "0.12" 5 | 6 | script: 7 | - npm run test 8 | - ./generate-coverage.sh 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | -------------------------------------------------------------------------------- /test/runner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | data-canvas tests 5 | 6 | 7 | 8 |
9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /test/coverage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | data-canvas tests 5 | 6 | 7 | 8 |
9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "data-canvas", 3 | "version": "0.1.1", 4 | "description": "Improved event handling and testing for the HTML5 canvas", 5 | "main": "index.js", 6 | "browserify": "src/data-canvas.js", 7 | "scripts": { 8 | "test": "mocha-phantomjs test/runner.html" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/hammerlab/data-canvas.git" 13 | }, 14 | "keywords": [ 15 | "canvas" 16 | ], 17 | "author": "Dan Vanderkam (danvdk@gmail.com)", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/hammerlab/data-canvas/issues" 21 | }, 22 | "homepage": "https://github.com/hammerlab/data-canvas#readme", 23 | "devDependencies": { 24 | "chai": "^3.3.0", 25 | "coveralls": "^2.11.4", 26 | "istanbul": "^0.3.21", 27 | "mocha": "^2.3.3", 28 | "mocha-phantomjs": "3.5.3", 29 | "mocha-phantomjs-istanbul": "0.0.2", 30 | "phantomjs": "^1.9.18" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /generate-coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Generate code coverage data for posting to Coveralls. 3 | # Output is coverage/lcov.info 4 | 5 | set -o errexit 6 | set -x 7 | 8 | # Instrument the source code with Istanbul's __coverage__ variable. 9 | rm -rf coverage/* # Clear out everything to ensure a hermetic run. 10 | istanbul instrument --output coverage src 11 | 12 | # Run the tests using mocha-phantomjs & mocha-phantomjs-istanbul 13 | # This produces coverage/coverage.json 14 | phantomjs \ 15 | ./node_modules/mocha-phantomjs/lib/mocha-phantomjs.coffee \ 16 | test/coverage.html \ 17 | spec '{"hooks": "mocha-phantomjs-istanbul", "coverageFile": "coverage/coverage.json"}' 18 | 19 | if [ $CI ]; then 20 | # Convert the JSON coverage to LCOV for coveralls. 21 | istanbul report --include coverage/*.json lcovonly 22 | 23 | # Post the results to coveralls.io 24 | set +o errexit 25 | cat coverage/lcov.info | coveralls 26 | 27 | echo '' # reset exit code -- failure to post coverage shouldn't be an error. 28 | 29 | else 30 | # Convert the JSON coverage to HTML for viewing 31 | istanbul report --include coverage/*.json html 32 | set +x 33 | 34 | echo 'To browse coverage, run:' 35 | echo 36 | echo ' open coverage/index.html' 37 | echo 38 | fi 39 | -------------------------------------------------------------------------------- /flowtype/types.js: -------------------------------------------------------------------------------- 1 | // These are type declarations for use with Flow 2 | // http://flowtype.org/ 3 | 4 | declare module "data-canvas" { 5 | 6 | declare class DataCanvasRenderingContext2D extends CanvasRenderingContext2D { 7 | pushObject(o: any): void; 8 | popObject(): void; 9 | reset(): void; 10 | } 11 | 12 | declare function getDataContext(ctx: CanvasRenderingContext2D): DataCanvasRenderingContext2D; 13 | declare function getDataContext(canvas: HTMLCanvasElement): DataCanvasRenderingContext2D; 14 | 15 | declare class DataContext extends DataCanvasRenderingContext2D { 16 | constructor(ctx: CanvasRenderingContext2D): void; 17 | } 18 | 19 | declare class RecordingContext extends DataCanvasRenderingContext2D { 20 | constructor(ctx: CanvasRenderingContext2D): void; 21 | calls: Object[]; 22 | drawnObjectsWith(predicate: (o: Object)=>boolean): Object[]; 23 | callsOf(type: string): Array[]; 24 | 25 | static recordAll(): void; 26 | static reset(): void; 27 | 28 | static drawnObjectsWith(div: HTMLElement, selector: string, predicate:(o: Object)=>boolean): Object[]; 29 | static drawnObjectsWith(predicate:(o: Object)=>boolean): Object[]; 30 | 31 | static drawnObjects(div: HTMLElement, selector: string): Object[]; 32 | static drawnObjects(): Object[]; 33 | 34 | static callsOf(div: HTMLElement, selector: string, type: string): Array[]; 35 | static callsOf(type: string): Array[]; 36 | } 37 | 38 | declare class ClickTrackingContext extends DataCanvasRenderingContext2D { 39 | constructor(ctx: CanvasRenderingContext2D, x: number, y: number): void; 40 | hit: ?any[]; 41 | hits: any[][]; 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/hammerlab/data-canvas.svg?branch=travis-tests)](https://travis-ci.org/hammerlab/data-canvas) [![Coverage Status](https://coveralls.io/repos/hammerlab/data-canvas/badge.svg?branch=master&service=github)](https://coveralls.io/github/hammerlab/data-canvas?branch=master)[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/hammerlab/data-canvas?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 2 | data-canvas 3 | =========== 4 | 5 | data-canvas allows you to add event tracking and tests to existing [canvas][1] 6 | code without sacrificing performance and without forcing you to refactor. 7 | 8 | It does this by introducing a new abstraction to canvas: the data stack. 9 | 10 | Background 11 | ---------- 12 | 13 | The [HTML5 canvas][1] has several advantages over [SVG][], its main rival for 14 | graphics on the web: 15 | 16 | - Depending on the benchmark, it's anywhere from 10x to 300x faster than SVG. 17 | - It allows for a simpler coding style. Rather than setting up [elaborate data 18 | binding][2] to track updates, you just redraw the scene from scratch every 19 | time. 20 | 21 | That being said, canvas also has some major drawbacks: 22 | 23 | - It can only be used through JavaScript. Rather than styling elements 24 | declaratively using CSS, you have to style them in code. 25 | - It's harder to work with events. For example, when the user clicks on the 26 | canvas, it's difficult to determine exactly which element they clicked on. 27 | - It's harder to test. Assertions about individual pixels are hard to 28 | understand and can break easily. With SVG, you can find complete shapes using 29 | selectors and make assertions about them. 30 | 31 | data-canvas aims to overcome some of these drawbacks without compromising 32 | canvas's speed and simplicity. 33 | 34 | A canvas example 35 | ---------------- 36 | 37 | Here's a bit of data representing a car: 38 | 39 | ```javascript 40 | var car = { 41 | name: 'Box car', 42 | x: 100, 43 | y: 100, 44 | width: 200, 45 | wheels: [ 46 | {name: 'back wheel', x: 20, radius: 15}, 47 | {name: 'front wheel', x: 180, radius: 10} 48 | ] 49 | }; 50 | ``` 51 | 52 | You might render it using canvas like so: 53 | 54 | ```javascript 55 | function renderScene(ctx, car) { 56 | ctx.fillStyle = 'red'; 57 | ctx.fillRect(car.x, car.y, car.width, -25); 58 | ctx.fillRect(car.x + 50, car.y - 25, car.width - 100, -25); 59 | ctx.fillStyle = 'black'; 60 | ctx.strokeStyle = 'gray'; 61 | car.wheels.forEach(function(wheel) { 62 | ctx.beginPath(); 63 | ctx.arc(car.x + wheel.x, car.y, wheel.radius, 0, Math.PI*2, true); 64 | ctx.fill(); 65 | ctx.stroke(); 66 | }); 67 | } 68 | 69 | function renderBoxyCar() { 70 | var ctx = canvas.getContext('2d'); 71 | renderScene(ctx, car); 72 | } 73 | ``` 74 | 75 | ![Boxy red car with two black wheels](https://cloud.githubusercontent.com/assets/98301/10149592/afbfddc6-6608-11e5-9ce8-ee5ef9bb0b7c.png) 76 | 77 | _(see [full demo][4])_ 78 | 79 | This is a beautiful car and a faithful rendering of the data. But what if you 80 | wanted to add a click handler to it? What if you wanted to write a test which 81 | asserted that there were two wheels? 82 | 83 | data-canvas can help you do both of these. 84 | 85 | The data stack 86 | -------------- 87 | [_"All problems in computer science can be solved by another level of indirection."_][3] 88 | 89 | data-canvas wraps the browser's canvas rendering context with a `DataContext`, 90 | which adds two new primitives: 91 | 92 | ``` 93 | declare class DataCanvasRenderingContext2D extends CanvasRenderingContext2D { 94 | pushObject(o: any): void; 95 | popObject(): void; 96 | } 97 | ``` 98 | 99 | These primitives associate a _data stack_ with the canvas rendering context. 100 | Whenever you render a bit of data, you should push it onto the data stack. When 101 | you're done with it, you pop it off. 102 | 103 | Here's what the car example looks like using a `DataContext`: 104 | 105 | ```javascript 106 | function renderScene(ctx) { 107 | ctx.pushObject(car); // <--- 108 | ctx.fillStyle = 'red'; 109 | ctx.fillRect(car.x, car.y, car.width, -25); 110 | ctx.fillRect(car.x + 50, car.y - 25, car.width - 100, -25); 111 | ctx.fillStyle = 'black'; 112 | ctx.strokeStyle = 'gray'; 113 | car.wheels.forEach(function(wheel) { 114 | ctx.pushObject(wheel); // <--- 115 | ctx.beginPath(); 116 | ctx.arc(car.x + wheel.x, car.y, wheel.radius, 0, Math.PI*2, true); 117 | ctx.fill(); 118 | ctx.stroke(); 119 | ctx.popObject(); // <--- 120 | }); 121 | ctx.popObject(); // <--- 122 | } 123 | 124 | function renderBoxyCar() { 125 | var ctx = dataCanvas.getDataContext(canvas.getContext('2d')); // <--- 126 | renderScene(ctx); 127 | } 128 | ``` 129 | 130 | The new code is marked by comments. The `pushObject`/`popObject` calls fit 131 | nicely into the existing code without changing its style. 132 | 133 | Here's what the data stack looks like while the rendering happens: 134 | 135 | 139 | 140 | 141 | Testing 142 | ------- 143 | Using this modified code, we can write a test: 144 | 145 | ```javascript 146 | describe('boxy car', function() { 147 | it('should have two wheels', function() { 148 | var RecordingContext = dataCanvas.RecordingContext; 149 | RecordingContext.recordAll(); // stub in a recording data context 150 | renderBoxyCar(); 151 | 152 | var wheels = RecordingContext.drawnObjectsWith(x => x.radius); 153 | expect(wheels).to.have.length(2); 154 | expect(wheels[0].name).to.equal('back wheel'); 155 | expect(wheels[1].name).to.equal('front wheel'); 156 | 157 | RecordingContext.reset(); // restore the usual data context 158 | }); 159 | }); 160 | ``` 161 | 162 | The `RecordingContext.recordAll()` call swaps in an alternate implementation of 163 | `DataContext` which records every method called on it. This includes the 164 | `pushObject` calls. After the drawing is done, we can access the drawn objects 165 | using its helper methods, such as `drawnObjectsWith`. 166 | 167 | It's typically easiest to make assertions about the objects pushed onto the 168 | data stack, but you can make assertions about the underlying drawing commands, too: 169 | 170 | ```javascript 171 | describe('boxy car', function() { 172 | it('should draw two wheels', function() { 173 | var RecordingContext = dataCanvas.RecordingContext; 174 | RecordingContext.recordAll(); // stub in a recording data context 175 | renderBoxyCar(); 176 | RecordingContext.reset(); // restore the usual data context 177 | 178 | var wheels = RecordingContext.callsOf('arc'); 179 | expect(wheels).to.have.length(2); 180 | expect(wheels[0].slice(0, 3)).to.deep.equal(['arc', 120, 100, 15]); 181 | expect(wheels[1].slice(0, 3)).to.deep.equal(['arc', 280, 100, 10]); 182 | }); 183 | }); 184 | ``` 185 | 186 | Writing the test required no modifications to the rendering code beyond the 187 | `pushObject`/`popObject` calls. 188 | 189 | 190 | Click tracking 191 | -------------- 192 | 193 | data-canvas also facilitates mapping (x, y) coordinates to objects in the scene. 194 | 195 | Suppose you wanted to add click handlers to the wheels and the car itself. 196 | Here's how you might do that: 197 | 198 | ```javascript 199 | canvas.onclick = function(e) { 200 | var ctx = canvas.getContext('2d'); 201 | var trackingContext = new dataCanvas.ClickTrackingContext(ctx, e.offsetX, e.offsetY); 202 | renderScene(trackingContext); 203 | 204 | if (trackingContext.hit) { 205 | alert(trackingContext.hit[0].name); 206 | } 207 | }; 208 | ``` 209 | _(Try it with [this fiddle][5])_ 210 | 211 | Again, no modifications to the scene rendering code were required. To determine 212 | which object (if any) was clicked on, we swapped in an alternate data context 213 | (`ClickTrackingContext`) and redrew the scene. 214 | 215 | While redrawing the scene may feel inefficient, it rarely is. 216 | `ClickTrackingContext` doesn't need to draw any shapes, only check whether they 217 | contain the relevant point. 218 | 219 | After rendering the scene, `ClickTrackingContext` will have both a `hit` and a 220 | `hits` property. `hit` records the contents of the data stack for the last 221 | (top-most) shape which contains the point. `hits` records the contents for all 222 | shapes which contained the point, ordered from top to bottom. 223 | 224 | For example, if you click the top part of the back wheel, then we'll have: 225 | 226 | `hit = [back wheel, car]` 227 | 228 | Because both the car and the back wheel were on the data stack when the car was drawn. 229 | 230 | We'll also have: 231 | 232 | `hits = [[back wheel, car], [car]]` 233 | 234 | This is because only `car` was on the stack when the car rectangle was drawn, 235 | and another shape (the wheel) was drawn on top of it. 236 | 237 | 238 | Usage 239 | ----- 240 | 241 | To install data-canvas, use NPM: 242 | 243 | npm install data-canvas 244 | 245 | Then include it either in your page: 246 | 247 | 248 | 249 | or require it: 250 | 251 | var dataCanvas = require('data-canvas'); 252 | 253 | data-canvas comes with type bindings for [Flow][]. To use these, add the 254 | following to your `.flowconfig`: 255 | 256 | ``` 257 | [ignore] 258 | .*node_modules/data-canvas.* 259 | 260 | [lib] 261 | node_modules/data-canvas/flowtype 262 | ``` 263 | 264 | 265 | [1]: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API 266 | [svg]: https://developer.mozilla.org/en-US/docs/Web/SVG 267 | [2]: http://alignedleft.com/tutorials/d3/binding-data/ 268 | [3]: http://www.dmst.aueb.gr/dds/pubs/inbook/beautiful_code/html/Spi07g.html 269 | [4]: http://jsfiddle.net/7nkbfbkb/1/ 270 | [5]: http://jsfiddle.net/7nkbfbkb/3/ 271 | [flow]: http://flowtype.org 272 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /src/data-canvas.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wrappers around CanvasRenderingContext2D to facilitate testing and click-tracking. 3 | * 4 | * This adds the concept of a "data stack" to the canvas. When shapes are 5 | * drawn, they represent the objects currently on the stack. This stack can be 6 | * manipulated using context.pushObject() and context.popObject(). 7 | * 8 | * See test file for sample usage. 9 | */ 10 | 11 | (function() { 12 | 13 | 'use strict'; 14 | 15 | // Turn obj into a proxy for target. This forwards both function calls and 16 | // property setters/getters. 17 | function forward(obj, target, onlyAccessors) { 18 | onlyAccessors = onlyAccessors || false; 19 | for (var k in target) { 20 | (function(k) { 21 | if (typeof(target[k]) == 'function') { 22 | if (!onlyAccessors) { 23 | obj[k] = target[k].bind(target); 24 | } 25 | } else { 26 | Object.defineProperty(obj, k, { 27 | get: function() { return target[k]; }, 28 | set: function(x) { target[k] = x; } 29 | }); 30 | } 31 | })(k); 32 | } 33 | } 34 | 35 | // The most basic data-aware canvas. This throws away all data information. 36 | // Use this for basic drawing 37 | function DataContext(ctx) { 38 | forward(this, ctx); 39 | this.pushObject = this.popObject = this.reset = function() {}; 40 | } 41 | 42 | var stubGetDataContext = null; 43 | 44 | /** 45 | * Get a DataContext for the built-in CanvasRenderingContext2D. 46 | * 47 | * This caches DataContexts and facilitates stubbing in tests. 48 | * 49 | * As a convenience, you may pass in a Canvas element instead of a 50 | * CanvasRenderingContext2D. data-canvas will call getContext('2d') for you. 51 | */ 52 | function getDataContext(ctxOrCanvas) { 53 | if (ctxOrCanvas instanceof HTMLCanvasElement) { 54 | return getDataContext(ctxOrCanvas.getContext('2d')); 55 | } 56 | 57 | var ctx = ctxOrCanvas; 58 | if (stubGetDataContext) { 59 | return stubGetDataContext(ctx); 60 | } else { 61 | for (var i = 0; i < getDataContext.cache.length; i++) { 62 | var pair = getDataContext.cache[i]; 63 | if (pair[0] == ctx) return pair[1]; 64 | } 65 | var dtx = new DataContext(ctx); 66 | getDataContext.cache.push([ctx, dtx]); 67 | return dtx; 68 | } 69 | } 70 | getDataContext.cache = []; // (CanvasRenderingContext2D, DataContext) pairs 71 | 72 | 73 | /** 74 | * A context which records what it does (for testing). 75 | * 76 | * This proxies all calls to the underlying canvas, so they do produce visible 77 | * drawing. Use `drawnObjectsWith` or `calls` to test what was drawn. 78 | */ 79 | function RecordingContext(ctx) { 80 | forward(this, ctx, true /* only foward accessors */); 81 | 82 | var calls = []; 83 | this.calls = calls; 84 | 85 | for (var k in ctx) { 86 | (function(k) { 87 | if (typeof(ctx[k]) != 'function') return; 88 | this[k] = function() { 89 | // TODO: record current drawing style 90 | var args = Array.prototype.slice.call(arguments); 91 | calls.push([k].concat(args)); 92 | return ctx[k].apply(ctx, arguments); 93 | }; 94 | }).bind(this)(k); 95 | } 96 | 97 | this.pushObject = function(o) { 98 | calls.push(['pushObject', o]); 99 | }; 100 | 101 | this.popObject = function() { 102 | calls.push(['popObject']); 103 | }; 104 | 105 | this.reset = function() { 106 | this.calls = calls = []; 107 | }; 108 | 109 | var recordingDrawImage = this.drawImage; // plain recording drawImage() 110 | this.drawImage = function(image) { 111 | // If the drawn image has recorded calls, then they need to be transferred over. 112 | var recorder = RecordingContext.recorderForCanvas(image); 113 | if (!recorder) { 114 | recordingDrawImage.apply(ctx, arguments); 115 | } else { 116 | ctx.drawImage.apply(ctx, arguments); 117 | calls = calls.concat(transformedCalls(recorder.calls, arguments)); 118 | this.calls = calls; 119 | } 120 | } 121 | } 122 | 123 | // Transform the calls to a new coordinate system. 124 | // The arguments are those to drawImage(). 125 | function transformedCalls(calls, args) { 126 | var image = args[0], 127 | sx = 0, 128 | sy = 0, 129 | sWidth = image.width, 130 | sHeight = image.height, 131 | dx, 132 | dy, 133 | dWidth = image.width, 134 | dHeight = image.height; 135 | 136 | if (args.length == 3) { 137 | // void ctx.drawImage(image, dx, dy); 138 | dx = args[1]; 139 | dy = args[2]; 140 | } else if (args.length == 5) { 141 | // void ctx.drawImage(image, dx, dy, dWidth, dHeight); 142 | dx = args[1]; 143 | dy = args[2]; 144 | dWidth = args[3]; 145 | dHeight = args[4]; 146 | } else if (args.length == 9) { 147 | // void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight); 148 | sx = args[1]; 149 | sy = args[2]; 150 | sWidth = args[3]; 151 | sHeight = args[4]; 152 | dx = args[5]; 153 | dy = args[6]; 154 | dWidth = args[7]; 155 | dHeight = args[8]; 156 | } 157 | // Other arities will make the browser throw an error on ctx.drawImage.apply 158 | 159 | var xScaling = getScaleFactor(sx, sx + sWidth, dx, dx + dWidth), 160 | xScale = makeScale( sx, sx + sWidth, dx, dx + dWidth), 161 | yScaling = getScaleFactor(sy, sy + sHeight, dy, dy + dHeight), 162 | yScale = makeScale( sy, sy + sHeight, dy, dy + dHeight); 163 | 164 | // These calls are more complex: 165 | // arc 166 | // arcTo 167 | // ellipse 168 | 169 | // TODO: clip calls outside of the source rectangle. 170 | var transformCall = function(originalCall) { 171 | var call = originalCall.slice(), // make a copy 172 | type = call[0]; 173 | if (type in CALLS_XY) { 174 | var xys = CALLS_XY[type]; 175 | if (typeof(xys) == 'number') xys = [xys]; 176 | xys.forEach(function(pos) { 177 | call[1 + pos] = xScale(call[1 + pos]); 178 | call[2 + pos] = yScale(call[2 + pos]); 179 | }); 180 | } 181 | if (type in CALLS_WH) { 182 | var whs = CALLS_WH[type]; 183 | if (typeof(whs) == 'number') whs = [whs]; 184 | whs.forEach(function(pos) { 185 | call[1 + pos] *= xScaling; 186 | call[2 + pos] *= yScaling; 187 | }); 188 | } 189 | return call; 190 | }; 191 | 192 | return calls.map(transformCall); 193 | } 194 | 195 | // Helpers for transformedCalls 196 | 197 | // Map (x1, x2) --> (y1, y2) 198 | function getScaleFactor(x1, x2, y1, y2) { 199 | return (y2 - y1) / (x2 - x1); 200 | }; 201 | function makeScale(x1, x2, y1, y2) { 202 | var scale = getScaleFactor(x1, x2, y1, y2); 203 | return function(x) { 204 | return y1 + scale * (x - x1); 205 | }; 206 | }; 207 | 208 | // These calls all have (x, y) as args at the specified positions. 209 | var CALLS_XY = { 210 | clearRect: 0, 211 | fillRect: 0, 212 | strokeRect: 0, 213 | fillText: 1, 214 | strokeText: 1, 215 | moveTo: 0, 216 | lineTo: 0, 217 | bezierCurveTo: [0, 2, 4], 218 | quadraticCurveTo: [0, 2], 219 | rect: 0 220 | }; 221 | // These calls have (width, height) as args at the specified positions. 222 | var CALLS_WH = { 223 | clearRect: 2, 224 | fillRect: 2, 225 | strokeRect: 2, 226 | // fillText has an optional `max_width` param 227 | rect: 2, 228 | }; 229 | 230 | /** 231 | * Get a list of objects which have been pushed to the data canvas that match 232 | * the particular predicate. 233 | * If no predicate is specified, all objects are returned. 234 | */ 235 | RecordingContext.prototype.drawnObjectsWith = function(predicate) { 236 | if (!predicate) predicate = function() { return true; }; 237 | return this.callsOf('pushObject') 238 | .filter(function(x) { return predicate(x[1]) }) 239 | .map(function(x) { return x[1]; }); 240 | }; 241 | // This version reads better if there's no predicate. 242 | RecordingContext.prototype.drawnObjects = RecordingContext.prototype.drawnObjectsWith; 243 | 244 | /** 245 | * Find calls of a particular type, e.g. `fillText` or `pushObject`. 246 | * 247 | * Returns an array of the calls and their parameters, e.g. 248 | * [ ['fillText', 'Hello!', 20, 10] ] 249 | */ 250 | RecordingContext.prototype.callsOf = function(type) { 251 | return this.calls.filter(function(call) { return call[0] == type }); 252 | }; 253 | 254 | /** 255 | * Static method to begin swapping in RecordingContext in place of DataContext. 256 | * Don't forget to call RecordingContext.reset() after the test completes! 257 | */ 258 | RecordingContext.recordAll = function() { 259 | if (stubGetDataContext != null) { 260 | throw 'You forgot to call RecordingContext.reset()'; 261 | } 262 | RecordingContext.recorders = []; 263 | stubGetDataContext = function(ctx) { 264 | var recorder = RecordingContext.recorderForCanvas(ctx.canvas); 265 | if (recorder) return recorder; 266 | 267 | recorder = new RecordingContext(ctx); 268 | RecordingContext.recorders.push([ctx.canvas, recorder]); 269 | return recorder; 270 | }; 271 | }; 272 | 273 | /** 274 | * Revert the stubbing performed by RecordingContext.recordAll. 275 | */ 276 | RecordingContext.reset = function() { 277 | if (!stubGetDataContext) { 278 | throw 'Called RecordingContext.reset() before RecordingContext.recordAll()'; 279 | } 280 | stubGetDataContext = null; 281 | RecordingContext.recorders = null; 282 | }; 283 | 284 | // Get the recording context for a canvas. 285 | RecordingContext.recorderForCanvas = function(canvas) { 286 | var recorders = RecordingContext.recorders; 287 | if (recorders == null) { 288 | throw 'You must call RecordingContext.recordAll() before using other RecordingContext static methods'; 289 | } 290 | for (var i = 0; i < recorders.length; i++) { 291 | var r = recorders[i]; 292 | if (r[0] == canvas) return r[1]; 293 | } 294 | return null; 295 | }; 296 | 297 | /** 298 | * Get the recording context for a canvas inside of div.querySelector(selector). 299 | * 300 | * This is useful when you have a test div and several canvases. 301 | */ 302 | RecordingContext.recorderForSelector = function(div, selector) { 303 | var canvas = div.querySelector(selector + ' canvas') || div.querySelector(selector); 304 | if (!canvas) { 305 | throw 'Unable to find a canvas matching ' + selector; 306 | } else if (!(canvas instanceof HTMLCanvasElement)) { 307 | throw 'Selector ' + selector + ' neither matches nor contains a canvas'; 308 | } 309 | return RecordingContext.recorderForCanvas(canvas); 310 | }; 311 | 312 | // Resolves arguments for RecordingContext helpers. 313 | // You can either specify a div & selector to find the canvas, or omit this if 314 | // there's only one canvas being recorded. 315 | function findRecorder(div, selector) { 316 | if (!div) { 317 | if (!RecordingContext.recorders) { 318 | throw 'You must call RecordingContext.recordAll() before using other RecordingContext static methods'; 319 | } else if (RecordingContext.recorders.length == 0) { 320 | throw 'Called a RecordingContext method, but no canvases are being recorded.'; 321 | } else if (RecordingContext.recorders.length > 1) { 322 | throw 'Called a RecordingContext method while multiple canvases were being recorded. Specify one using a div and selector.'; 323 | } else { 324 | return RecordingContext.recorders[0][1]; 325 | } 326 | } else { 327 | return RecordingContext.recorderForSelector(div, selector); 328 | } 329 | } 330 | 331 | // Find objects pushed onto a particular recorded canvas. 332 | RecordingContext.drawnObjectsWith = function(div, selector, predicate) { 333 | // Check for the zero-argument or one-argument version. 334 | if (typeof(div) == 'function' || arguments.length == 0) { 335 | predicate = div; 336 | div = null; 337 | } 338 | var recorder = findRecorder(div, selector); 339 | predicate = predicate || function() { return true; }; 340 | return recorder ? recorder.drawnObjectsWith(predicate) : []; 341 | }; 342 | 343 | // This version reads better if there's no predicate. 344 | RecordingContext.drawnObjects = RecordingContext.drawnObjectsWith; 345 | 346 | // Find calls of particular drawing functions (e.g. fillText) 347 | RecordingContext.callsOf = function (div, selector, type) { 348 | // Check for the one-argument version. 349 | if (typeof(div) == 'string') { 350 | type = div; 351 | div = null; 352 | } 353 | var recorder = findRecorder(div, selector); 354 | return recorder ? recorder.callsOf(type) : []; 355 | }; 356 | 357 | 358 | /** 359 | * A context which determines the data at a particular location. 360 | * 361 | * When drawing methods are called on this class, nothing is rendered. Instead, 362 | * each shape is checked to see if it includes the point of interest. If it 363 | * does, the current data stack is saved as a "hit". 364 | * 365 | * The `hits` property records all such hits. 366 | * The `hit` property records only the last (top) hit. 367 | */ 368 | function ClickTrackingContext(ctx, px, py) { 369 | forward(this, ctx); 370 | 371 | var stack = []; 372 | this.hits = []; 373 | this.hit = null; 374 | 375 | var that = this; 376 | function recordHit() { 377 | that.hits.unshift(Array.prototype.slice.call(stack)); 378 | that.hit = that.hits[0]; 379 | } 380 | 381 | this.pushObject = function(o) { 382 | stack.unshift(o); 383 | }; 384 | 385 | this.popObject = function() { 386 | stack.shift(); 387 | }; 388 | 389 | this.reset = function() { 390 | this.hits = []; 391 | this.hit = null; 392 | }; 393 | 394 | // These are (most of) the canvas methods which draw something. 395 | // TODO: would it make sense to purge existing hits covered by this? 396 | this.clearRect = function(x, y, w, h) { }; 397 | 398 | this.fillRect = function(x, y, w, h) { 399 | if (px >= x && px <= x + w && py >= y && py <= y + h) recordHit(); 400 | }; 401 | 402 | this.strokeRect = function(x, y, w, h) { 403 | // ... 404 | }; 405 | 406 | this.fill = function(fillRule) { 407 | // TODO: implement fillRule 408 | if (ctx.isPointInPath(px, py)) recordHit(); 409 | }; 410 | 411 | this.stroke = function() { 412 | if (ctx.isPointInStroke(px, py)) recordHit(); 413 | }; 414 | 415 | this.fillText = function(text, x, y, maxWidth) { 416 | // ... 417 | }; 418 | 419 | this.strokeText = function(text, x, y, maxWidth) { 420 | // ... 421 | }; 422 | } 423 | 424 | var exports = { 425 | DataContext: DataContext, 426 | RecordingContext: RecordingContext, 427 | ClickTrackingContext: ClickTrackingContext, 428 | getDataContext: getDataContext 429 | }; 430 | 431 | if (typeof(module) !== 'undefined') { 432 | /* istanbul ignore next */ 433 | module.exports = exports; 434 | } else { 435 | window.dataCanvas = exports; 436 | } 437 | 438 | })(); 439 | -------------------------------------------------------------------------------- /test/data-canvas-test.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | 'use strict'; 4 | 5 | var expect = chai.expect; 6 | 7 | describe('data-canvas', function() { 8 | var testDiv = document.getElementById('testdiv'); 9 | var canvas; 10 | 11 | before(function() { 12 | canvas = document.createElement('canvas'); 13 | canvas.width = 600; 14 | canvas.height = 200; 15 | testDiv.appendChild(canvas); 16 | }); 17 | 18 | after(function() { 19 | // testDiv.innerHTML = ''; // avoid pollution between tests. 20 | }); 21 | 22 | function rgbAtPos(im, x, y) { 23 | var index = y * (im.width * 4) + x * 4; 24 | return [ 25 | im.data[index], 26 | im.data[index + 1], 27 | im.data[index + 2] 28 | ]; 29 | } 30 | 31 | describe('DataContext', function() { 32 | it('should put pixels on the canvas', function() { 33 | if (!canvas) throw 'bad'; // for flow 34 | var ctx = canvas.getContext('2d'); 35 | var dtx = dataCanvas.getDataContext(ctx); 36 | 37 | dtx.fillStyle = 'red'; 38 | dtx.fillRect(100, 50, 200, 25); 39 | dtx.pushObject({something: 'or other'}); 40 | dtx.popObject(); 41 | 42 | var im = ctx.getImageData(0, 0, 600, 400); 43 | expect(rgbAtPos(im, 50, 50)).to.deep.equal([0, 0, 0]); 44 | expect(rgbAtPos(im, 200, 60)).to.deep.equal([255, 0, 0]); 45 | }); 46 | 47 | it('should cache calls', function() { 48 | if (!canvas) throw 'bad'; // for flow 49 | var ctx = canvas.getContext('2d'); 50 | var dtx = dataCanvas.getDataContext(canvas); 51 | var dtx2 = dataCanvas.getDataContext(ctx); 52 | 53 | expect(dtx2).to.equal(dtx); 54 | }); 55 | 56 | it('should support read/write to properties', function() { 57 | var dtx = dataCanvas.getDataContext(canvas); 58 | dtx.lineWidth = 10; 59 | expect(dtx.lineWidth).to.equal(10); 60 | }); 61 | }); 62 | 63 | describe('ClickTrackingContext', function() { 64 | var ctx; 65 | before(function() { 66 | if (!canvas) throw 'bad'; // for flow 67 | ctx = canvas.getContext('2d'); 68 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 69 | }); 70 | 71 | function getObjectsAt(draw, x, y) { 72 | var dtx = new dataCanvas.ClickTrackingContext(ctx, x, y); 73 | draw(dtx); 74 | return dtx.hits; 75 | } 76 | 77 | // To draw any of these: 78 | // draw(dataCanvas.getDataContext(ctx)); 79 | 80 | it('should track clicks on rects', function() { 81 | function draw(dtx) { 82 | dtx.pushObject('r'); 83 | dtx.fillStyle = 'red'; 84 | dtx.fillRect(100, 50, 100, 25); 85 | dtx.popObject(); 86 | dtx.pushObject('b'); 87 | dtx.fillStyle = 'blue'; 88 | dtx.fillRect(300, 100, 200, 25); 89 | dtx.popObject(); 90 | } 91 | 92 | expect(getObjectsAt(draw, 150, 60)).to.deep.equal([['r']]); 93 | expect(getObjectsAt(draw, 350, 110)).to.deep.equal([['b']]); 94 | expect(getObjectsAt(draw, 250, 110)).to.deep.equal([]); 95 | }); 96 | 97 | it('should track clicks on complex shapes', function() { 98 | function draw(dtx) { 99 | // This is the upper right half of a rectangle, i.e. a triangle. 100 | dtx.pushObject('triangle'); 101 | dtx.beginPath(); 102 | dtx.moveTo(100, 100); 103 | dtx.lineTo(400, 100); 104 | dtx.lineTo(400, 200); 105 | dtx.closePath(); 106 | dtx.fill(); 107 | dtx.popObject(); 108 | } 109 | 110 | // This point is in the top right (and hence in the triangle) 111 | expect(getObjectsAt(draw, 300, 110)).to.deep.equal([['triangle']]); 112 | // This poitn is in the bottom left (and hence not in the triangle) 113 | expect(getObjectsAt(draw, 200, 180)).to.deep.equal([]); 114 | }); 115 | 116 | it('should track clicks on stacked shapes', function() { 117 | function draw(dtx) { 118 | dtx.pushObject('bottom'); 119 | dtx.fillStyle = 'red'; 120 | dtx.fillRect(100, 50, 400, 100); 121 | dtx.pushObject('top'); 122 | dtx.fillStyle = 'blue'; 123 | dtx.fillRect(200, 75, 100, 50); 124 | dtx.popObject(); 125 | dtx.popObject(); 126 | dtx.pushObject('side'); 127 | dtx.fillStyle = 'green'; 128 | dtx.fillRect(450, 75, 100, 50); 129 | dtx.popObject(); 130 | } 131 | 132 | draw(dataCanvas.getDataContext(ctx)); 133 | expect(getObjectsAt(draw, 110, 60)).to.deep.equal([['bottom']]); 134 | expect(getObjectsAt(draw, 250, 100)).to.deep.equal([['top', 'bottom'], ['bottom']]); 135 | expect(getObjectsAt(draw, 475, 100)).to.deep.equal([['side'], ['bottom']]); 136 | }); 137 | 138 | it('should reset hit tracker', function() { 139 | function draw(dtx) { 140 | dtx.reset(); 141 | dtx.clearRect(0, 0, dtx.canvas.width, dtx.canvas.height); 142 | dtx.pushObject('rect'); 143 | dtx.fillRect(100, 10, 200, 30); 144 | dtx.popObject(); 145 | } 146 | function doubledraw(dtx) { 147 | draw(dtx); 148 | draw(dtx); 149 | } 150 | 151 | // Despite the double-drawing, only one object matches, not two. 152 | // This is because of the reset() call. 153 | doubledraw(dataCanvas.getDataContext(ctx)); 154 | expect(getObjectsAt(doubledraw, 110, 30)).to.deep.equal([['rect']]); 155 | }); 156 | 157 | // PhantomJS 1.9.x does not support isStrokeInPath 158 | // When Travis-CI updates to Phantom2, this can be re-enabled. 159 | // See https://github.com/ariya/phantomjs/issues/12948 160 | if (!navigator.userAgent.match(/PhantomJS\/1.9/)) { 161 | it('should detect clicks in strokes', function() { 162 | function draw(dtx) { 163 | dtx.save(); 164 | dtx.pushObject('shape'); 165 | dtx.lineWidth = 5; 166 | dtx.beginPath(); 167 | dtx.moveTo(100, 10); 168 | dtx.lineTo(200, 10); 169 | dtx.lineTo(200, 30); 170 | dtx.lineTo(100, 30); 171 | dtx.closePath(); 172 | dtx.stroke(); 173 | dtx.popObject(); 174 | dtx.restore(); 175 | } 176 | 177 | draw(dataCanvas.getDataContext(ctx)); 178 | // a click on the stroke is a hit... 179 | expect(getObjectsAt(draw, 100, 10)).to.deep.equal([['shape']]); 180 | // ... while a click in the interior is not. 181 | expect(getObjectsAt(draw, 150, 20)).to.deep.equal([]); 182 | }); 183 | } 184 | 185 | }); 186 | 187 | describe('RecordingContext', function() { 188 | var RecordingContext = dataCanvas.RecordingContext; 189 | 190 | var ctx; 191 | before(function() { 192 | if (!canvas) throw 'bad'; // for flow 193 | ctx = canvas.getContext('2d'); 194 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 195 | }); 196 | 197 | describe('single canvas', function() { 198 | it('should record calls', function() { 199 | var dtx = new RecordingContext(ctx); 200 | dtx.fillStyle = 'red'; 201 | dtx.pushObject('a'); 202 | dtx.fillRect(100, 50, 200, 25); 203 | dtx.popObject(); 204 | 205 | expect(dtx.calls).to.have.length(3); // push, fill, pop 206 | expect(dtx.drawnObjectsWith(function(x) { return x == 'a' })).to.have.length(1); 207 | expect(dtx.drawnObjectsWith(function(x) { return x == 'b' })).to.have.length(0); 208 | 209 | // TODO: check drawing styles 210 | }); 211 | 212 | it('should return values from proxied functions', function() { 213 | var dtx = new RecordingContext(ctx); 214 | var metrics = dtx.measureText('Hello'); 215 | 216 | expect(dtx.calls).to.deep.equal([['measureText', 'Hello']]); 217 | expect(metrics.width).to.be.greaterThan(0); 218 | }); 219 | 220 | it('should provid static testing methods', function() { 221 | RecordingContext.recordAll(); 222 | var dtx = dataCanvas.getDataContext(ctx); 223 | dtx.pushObject('hello'); 224 | dtx.fillText('hello', 100, 10); 225 | dtx.popObject(); 226 | 227 | expect(RecordingContext.drawnObjects()).to.deep.equal(['hello']); 228 | expect(RecordingContext.drawnObjectsWith(function(x) { return x == 'hello' })).to.deep.equal(['hello']); 229 | expect(RecordingContext.callsOf('fillText')).to.deep.equal( 230 | [['fillText', 'hello', 100, 10]]); 231 | 232 | RecordingContext.reset(); 233 | }); 234 | 235 | it('should reset the list of calls', function() { 236 | function render(dtx) { 237 | dtx.reset(); // this clears the list of calls 238 | dtx.pushObject('hello'); 239 | dtx.fillText('hello', 100, 10); 240 | dtx.popObject(); 241 | } 242 | 243 | RecordingContext.recordAll(); 244 | var dtx = dataCanvas.getDataContext(ctx); 245 | render(dtx); 246 | render(dtx); 247 | 248 | // Only one object, not two (even though there are two render calls). 249 | expect(RecordingContext.drawnObjects()).to.have.length(1); 250 | 251 | RecordingContext.reset(); 252 | }); 253 | }); 254 | 255 | describe('multiple canvases', function() { 256 | var canvas2; 257 | before(function() { 258 | canvas2 = document.createElement('canvas'); 259 | canvas2.width = 400; 260 | canvas2.height = 100; 261 | canvas2.setAttribute('class', 'canvas2'); 262 | canvas.setAttribute('class', 'canvas1'); 263 | testDiv.appendChild(canvas2); 264 | }); 265 | 266 | it('should record calls to both canvases', function() { 267 | function render(dtx, text) { 268 | dtx.pushObject(text); 269 | dtx.fillText(text, 100, 10); 270 | dtx.popObject(); 271 | } 272 | 273 | RecordingContext.recordAll(); 274 | 275 | var dtx1 = dataCanvas.getDataContext(canvas), 276 | dtx2 = dataCanvas.getDataContext(canvas2); 277 | render(dtx1, 'Hello #1'); 278 | render(dtx2, 'Hello #2'); 279 | 280 | expect(function() { 281 | RecordingContext.drawnObjects(); 282 | }).to.throw(/multiple canvases/); 283 | 284 | expect(RecordingContext.drawnObjects(testdiv, '.canvas1')) 285 | .to.deep.equal(['Hello #1']); 286 | expect(RecordingContext.drawnObjects(testdiv, '.canvas2')) 287 | .to.deep.equal(['Hello #2']); 288 | 289 | expect(RecordingContext.callsOf(testdiv, '.canvas1', 'fillText')) 290 | .to.deep.equal([['fillText', 'Hello #1', 100, 10]]); 291 | expect(RecordingContext.callsOf(testdiv, '.canvas2', 'fillText')) 292 | .to.deep.equal([['fillText', 'Hello #2', 100, 10]]); 293 | 294 | expect(function() { 295 | RecordingContext.drawnObjects(testdiv, '.canvas3'); 296 | }).to.throw(/Unable to find.*\.canvas3/); 297 | 298 | RecordingContext.reset(); 299 | }); 300 | 301 | it('should throw on matching non-canvas', function() { 302 | testDiv.innerHTML += '
Foo
'; 303 | RecordingContext.recordAll(); 304 | expect(function() { 305 | RecordingContext.drawnObjects(testdiv, '.foo'); 306 | }).to.throw(/.foo neither matches nor contains/); 307 | RecordingContext.reset(); 308 | }); 309 | 310 | it('should throw before recording', function() { 311 | // TODO: this error message doesn't make much sense for a user. 312 | expect(function() { 313 | RecordingContext.drawnObjects(testdiv, '.canvas1'); 314 | }).to.throw(/must call .*recordAll.*other.*static methods/); 315 | }); 316 | }); 317 | 318 | describe('error cases', function() { 319 | it('should throw on reset before record', function() { 320 | expect(function() { 321 | RecordingContext.reset(); 322 | }).to.throw(/reset.*before.*recordAll/); 323 | }); 324 | 325 | it('should throw on double record', function() { 326 | expect(function() { 327 | RecordingContext.recordAll(); 328 | RecordingContext.recordAll(); 329 | }).to.throw(/forgot.*reset/); 330 | RecordingContext.reset(); 331 | }); 332 | 333 | it('should throw on access without recording', function() { 334 | expect(function() { 335 | RecordingContext.drawnObjects(); 336 | }).to.throw(/You must call .*recordAll/); 337 | }); 338 | 339 | it('should throw on access with nothing recorded', function() { 340 | expect(function() { 341 | RecordingContext.recordAll(); 342 | RecordingContext.drawnObjects(); 343 | }).to.throw(/no canvases are being recorded/); 344 | RecordingContext.reset(); 345 | }); 346 | }); 347 | 348 | describe('drawImage', function() { 349 | beforeEach(function() { 350 | RecordingContext.recordAll(); 351 | }); 352 | 353 | afterEach(function() { 354 | RecordingContext.reset(); 355 | }); 356 | 357 | function makeOffscreenImage() { 358 | var image = document.createElement('canvas'); 359 | image.width = 100; 360 | image.height = 100; 361 | var dtx = dataCanvas.getDataContext(image); 362 | dtx.pushObject('A'); 363 | dtx.fillRect(0, 0, 50, 50); 364 | dtx.popObject(); 365 | return image; 366 | } 367 | 368 | it('should transfer recorded calls', function() { 369 | var image = makeOffscreenImage(); 370 | var dtx = dataCanvas.getDataContext(canvas); 371 | dtx.drawImage(image, 0, 0); 372 | 373 | expect(dtx.calls).to.have.length(3); 374 | expect(dtx.drawnObjects()).to.deep.equal(['A']); 375 | expect(dtx.callsOf('fillRect')).to.deep.equal([['fillRect', 0, 0, 50, 50]]); 376 | // The drawImage call is elided. 377 | // This could be changed -- either way would be reasonable. 378 | expect(dtx.callsOf('drawImage')).to.deep.equal([]); 379 | }); 380 | 381 | it('should translate recorded calls', function() { 382 | var image = makeOffscreenImage(); 383 | var dtx = dataCanvas.getDataContext(canvas); 384 | dtx.drawImage(image, 50, 0); // dx=50 385 | 386 | expect(dtx.calls).to.have.length(3); 387 | expect(dtx.callsOf('fillRect')).to.deep.equal([['fillRect', 50, 0, 50, 50]]); 388 | }); 389 | 390 | it('should transform recorded calls', function() { 391 | var image = makeOffscreenImage(); 392 | var dtx = dataCanvas.getDataContext(canvas); 393 | dtx.drawImage(image, 50, 0, 75, 50); // dx=50, dWidth=75, dHeight=50 394 | 395 | expect(dtx.calls).to.have.length(3); 396 | expect(dtx.callsOf('fillRect')).to.deep.equal([['fillRect', 50, 0, 37.5, 25]]); 397 | }); 398 | 399 | it('should support a source rectangle', function() { 400 | var image = makeOffscreenImage(); 401 | var dtx = dataCanvas.getDataContext(canvas); 402 | // This copies x=75-100 and y=50-100 from source to dest 403 | dtx.drawImage(image, 25, 50, 75, 50, 0, 0, 75, 50); 404 | 405 | expect(dtx.calls).to.have.length(3); 406 | expect(dtx.callsOf('fillRect')).to.deep.equal([['fillRect', -25, -50, 50, 50]]); 407 | }); 408 | 409 | it('should reject invalid drawImage calls', function() { 410 | var image = makeOffscreenImage(); 411 | var dtx = dataCanvas.getDataContext(canvas); 412 | expect(function() { 413 | dtx.drawImage(image, 50, 0, 75); // four params, should be 3, 5 or 9 414 | }).to.throw(); // exact error depends on browser 415 | }); 416 | 417 | it('should transform paths', function() { 418 | var image = makeOffscreenImage(); 419 | var ctx = dataCanvas.getDataContext(image); 420 | ctx.beginPath(); 421 | ctx.moveTo(20, 10); 422 | ctx.lineTo(30, 20); 423 | ctx.quadraticCurveTo(50, 20, 40, 30); 424 | ctx.closePath(); 425 | 426 | var dtx = dataCanvas.getDataContext(canvas); 427 | dtx.drawImage(image, 0, 10, 50, 25); // dx=0, dy=10, dWidth=50, dHeight=25 428 | expect(dtx.callsOf('moveTo')).to.deep.equal([['moveTo', 10, 12.5]]); 429 | expect(dtx.callsOf('lineTo')).to.deep.equal([['lineTo', 15, 15]]); 430 | expect(dtx.callsOf('quadraticCurveTo')).to.deep.equal( 431 | [['quadraticCurveTo', 25, 15, 20, 17.5]]); 432 | }); 433 | 434 | it('should not transfer calls from unrecorded canvases', function() { 435 | var image = document.createElement('canvas'); 436 | image.width = 100; 437 | image.height = 100; 438 | image.getContext('2d').fillRect(0, 0, 100, 100); 439 | var dtx = dataCanvas.getDataContext(canvas); 440 | dtx.drawImage(image, 0, 0); 441 | 442 | // The fillRect call should not be transferred over. 443 | expect(dtx.callsOf('drawImage')).to.deep.equal( 444 | [['drawImage', image, 0, 0]]); 445 | expect(dtx.callsOf('fillRect')).to.deep.equal([]); 446 | }); 447 | 448 | // Regression test for #13 449 | it('should record calls after drawImage', function() { 450 | var image = makeOffscreenImage(); 451 | var dtx = dataCanvas.getDataContext(canvas); 452 | dtx.clearRect(0, 0, 200, 50); 453 | dtx.drawImage(image, 0, 0); 454 | dtx.fillRect(20, 10, 100, 40); 455 | 456 | expect(dtx.calls).to.have.length(5); 457 | expect(dtx.drawnObjects()).to.deep.equal(['A']); 458 | expect(dtx.callsOf('clearRect')).to.deep.equal([['clearRect', 0, 0, 200, 50]]); 459 | expect(dtx.callsOf('fillRect')).to.deep.equal([ 460 | ['fillRect', 0, 0, 50, 50], 461 | ['fillRect', 20, 10, 100, 40] 462 | ]); 463 | }); 464 | }); 465 | }); 466 | }); 467 | 468 | })(); 469 | --------------------------------------------------------------------------------