├── 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 |
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 += '