UNPKG

14.3 kBJavaScriptView Raw
1// Internal dependencies
2import CanvasDatapoint from './datapoint';
3
4/**
5 * UI canvas for displaying machine learning results
6 *
7 * Listeners:
8 * This class supports event listeners, meaning that the outside world can bind functions to events
9 * triggered explicitly by this class. Listeners can be added using `addListener` and removed by
10 * `removeListener`. The `emit` method is not intended for use by the outside world, and is used by
11 * this class to emit an event to the listeners bound to it.
12 */
13class Canvas {
14 constructor(el, options) {
15 // Settings for canvas
16 this.canvas = {
17 element: el,
18 context: el.getContext('2d'),
19 };
20
21 // Handle canvas resize on window resize
22 window.addEventListener('resize', () => this.resize());
23 this.resize();
24
25 // User-defined options
26 this.options = {
27 continuousClick: false,
28 continuousClickInterval: 50,
29 x1: -2.5,
30 y1: -2.5,
31 x2: 2.5,
32 y2: 2.5,
33 ...options,
34 };
35
36 // Event listeners bound to the canvas
37 this.listeners = new Map();
38
39 // Canvas elements to be drawn
40 this.elements = [];
41
42 // Class boundaries
43 this.classesBoundaries = {};
44
45 // Weights of classifiers
46 this.weights = null;
47 this.multiWeights = null;
48
49 // Initialization
50 this.handleMouseEvents();
51
52 // Animation
53 window.requestAnimationFrame(() => this.refresh());
54
55 // Temporary properties
56 this.tmp = {};
57 this.tmp.predFeatures = [];
58 this.tmp.predLabels = [];
59 }
60
61 /**
62 * Add an event listener for events of some type emitted from this object
63 *
64 * @param label Event
65 * @param callback Callback function
66 */
67 addListener(label, callback) {
68 if (!this.listeners.has(label)) {
69 this.listeners.set(label, []);
70 }
71
72 this.listeners.get(label).push(callback);
73 }
74
75 /**
76 * Remove a previously added event listener for events of some type emitted from this object
77 *
78 * @param label Event
79 * @param callback Callback function
80 */
81 removeListener(label, callback) {
82 const listeners = this.listeners.get(label);
83
84 if (listeners) {
85 this.listeners.set(label, listeners.filter(
86 x => !(typeof x === 'function' && x === callback)
87 ));
88 }
89 }
90
91 /**
92 * Emit an event, which triggers the listener callback functions bound to it
93 *
94 * @param label Event
95 * @param ...args Remaining arguments contain arguments that should be passed to the
96 * callback functions
97 */
98 emit(label, ...args) {
99 const listeners = this.listeners.get(label);
100
101 if (listeners) {
102 listeners.forEach((listener) => { listener(...args); });
103 return true;
104 }
105
106 return false;
107 }
108
109 /**
110 * Add a data point element to the canvas, using a dataset datapoint as its model
111 *
112 * @param jsml.Dataset.Datapoint datapoint Dataset datapoint (model)
113 */
114 addDatapoint(datapoint) {
115 this.elements.push(new CanvasDatapoint(this, datapoint));
116 }
117
118 /**
119 * Handle mouse events on the canvas, e.g. for adding data points
120 */
121 handleMouseEvents() {
122 if (this.options.continuousClick) {
123 this.mouseStatus = 0;
124 this.mouseX = 0;
125 this.mouseY = 0;
126
127 this.canvas.element.addEventListener('mousedown', () => {
128 this.mouseStatus = 1;
129 this.continuousClickIntervalId = setInterval(
130 () => this.click(),
131 this.options.continuousClickInterval
132 );
133 });
134
135 document.addEventListener('mouseup', () => {
136 this.mouseStatus = 0;
137 clearInterval(this.continuousClickIntervalId);
138 });
139
140 document.addEventListener('mousemove', (e) => {
141 [this.mouseX, this.mouseY] =
142 this.transformAbsolutePositionToRelativePosition(e.clientX, e.clientY);
143 });
144 }
145
146 this.canvas.element.addEventListener('mousedown', (e) => {
147 this.click(...this.transformAbsolutePositionToRelativePosition(e.clientX, e.clientY));
148 });
149 }
150
151 /**
152 * Transform the absolute position of the mouse in the viewport to the mouse position relative
153 * to the top-left point of the canvas
154 *
155 * @param float x Absolute mouse x-coordinate within viewport
156 * @param float y Absolute mouse y-coordinate within viewport
157 * @return array Two-dimensional array consisting of relative x- and y-coordinate
158 */
159 transformAbsolutePositionToRelativePosition(x, y) {
160 // Properties used for calculating mouse position
161 const el = this.canvas.element;
162 const rect = el.getBoundingClientRect();
163
164 return [x - rect.left, y - rect.top];
165 }
166
167 /**
168 * Trigger a click at some position in the canvas
169 *
170 * @param int x Optional. X-coordinate of the click. Defaults to stored mouse position from
171 * mousemove event
172 * @param int y Optional. Y-coordinate of the click. Defaults to stored mouse position from
173 * mousemove event
174 */
175 click(x = -1, y = -1) {
176 let clickX = x;
177 let clickY = y;
178
179 if (x === -1) {
180 clickX = this.mouseX;
181 clickY = this.mouseY;
182 }
183
184 // Calculate normalized coordinates with origin in canvas center
185 const [px, py] = this.convertCanvasCoordinatesToFeatures(clickX, clickY);
186
187 this.emit('click', px, py);
188 }
189
190 /**
191 * Clear the canvas
192 */
193 clear() {
194 this.canvas.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
195 }
196
197 /**
198 * Handle the canvas size for different device pixel ratios and on window resizes
199 */
200 resize() {
201 this.canvas.element.style.width = '100%';
202 this.canvas.element.style.height = '100%';
203 this.canvas.element.width = this.canvas.element.offsetWidth * window.devicePixelRatio;
204 this.canvas.element.height = this.canvas.element.offsetHeight * window.devicePixelRatio;
205 this.canvas.width = this.canvas.element.offsetWidth;
206 this.canvas.height = this.canvas.element.offsetHeight;
207 this.canvas.context.scale(window.devicePixelRatio, window.devicePixelRatio);
208 }
209
210 redraw() {
211 // Clear canvas
212 this.clear();
213
214 // Basic canvas elements
215 this.drawGrid();
216 this.drawAxes();
217
218 // Draw dynamic canvas elements
219 this.elements.forEach((element) => {
220 element.draw();
221 });
222
223 // Class boundaries
224 this.drawClassBoundaries();
225
226 // for (let i = 0; i < this.tmp.predFeatures.length; i++) {
227 // let [xx,yy] = this.convertFeaturesToCanvasCoordinates(
228 // this.tmp.predFeatures[i][0],
229 // this.tmp.predFeatures[i][1]
230 // );
231
232 // this.canvas.context.fillStyle = this.getClassColor(this.tmp.predLabels[i]);
233 // this.canvas.context.fillRect(xx - 2, yy - 2, 4, 4);
234 // }
235
236 // Draw the weight vector
237 // this.drawWeightVector(this.weights);
238
239 // if (Array.isArray(this.multiWeights)) {
240 // let i = 0;
241 // for (let weights of this.multiWeights) {
242 // context.strokeStyle = this.getClassColor(i);
243 // this.drawWeightVector(weights);
244 // i++;
245 // }
246 // }
247
248 // Refresh again
249 window.requestAnimationFrame(() => this.refresh());
250 }
251
252 /**
253 * Refresh (i.e. redraw) everything on the canvas
254 */
255 refresh() {
256 // Dynamic canvas elements
257 this.elements.forEach((element) => {
258 element.update();
259 });
260
261 this.redraw();
262 }
263
264 setWeightVector(weights) {
265 this.weights = weights;
266 }
267
268 /**
269 * Set the class boundaries used for drawing the decision regions on the canvas
270 *
271 * @param Map[string => Array[Array[Number]]] Class boundaries per class label
272 */
273 setClassBoundaries(classesBoundaries) {
274 this.classesBoundaries = classesBoundaries;
275 }
276
277 drawWeightVector(weights) {
278 if (weights) {
279 let fromX;
280 let fromY;
281 let toX;
282 let toY;
283
284 // w0 + w1 * x + w2 * y = 0
285
286 if (Math.abs(weights[1]) > Math.abs(weights[2])) {
287 fromX = -1;
288 fromY = -(weights[0] - weights[1]) / weights[2];
289 toX = 1;
290 toY = -(weights[0] + weights[1]) / weights[2];
291 } else {
292 fromY = -1;
293 fromX = -(weights[0] - weights[2]) / weights[1];
294 toY = 1;
295 toX = -(weights[0] + weights[2]) / weights[1];
296 }
297
298 const canvas = this.canvas;
299 const context = canvas.context;
300
301 fromX = (fromX + 1) / 2;
302 fromY = 1 - (fromY + 1) / 2;
303 toX = (toX + 1) / 2;
304 toY = 1 - (toY + 1) / 2;
305
306 context.beginPath();
307 context.moveTo(fromX * canvas.width, fromY * canvas.height);
308 context.lineTo(toX * canvas.width, toY * canvas.height);
309 context.lineWidth = 3;
310 // context.strokeStyle = '#BBB';
311 context.stroke();
312 }
313 }
314
315 /**
316 * Calculate normalized canvas coordinates, i.e. transform mouse coordinates (relative to the
317 * canvas origin = top left) to feature space for both x and y. The feature subspace shape is
318 * determined by the x1, y1, x2, and y2 parameters in the class options (see constructor)
319 *
320 * @param float x x-coordinate in canvas
321 * @param float y y-coordinate in canvas
322 * @return Array[double] Corresponding point in feature space (first element corresponds to x,
323 * second element corresponds to y)
324 */
325 convertCanvasCoordinatesToFeatures(x, y) {
326 // Mouse x- and y-position on [0,1] interval
327 let f1 = x / this.canvas.width;
328 let f2 = y / this.canvas.height;
329
330 // Convert to [-1,1] interval
331 f1 = this.options.x1 + f1 * (this.options.x2 - this.options.x1);
332 f2 = this.options.y1 + (1 - f2) * (this.options.y2 - this.options.y1);
333
334 return [f1, f2];
335 }
336
337 /**
338 * Convert coordinates on a centered, double unit square (i.e., a square from (-1, -1) to (1, 1))
339 * to feature space
340 *
341 * @param float bx Input x-coordinate in input space
342 * @param float by Input y-coordinate in input space
343 * @return Array[double] Corresponding point in feature space (first element corresponds to x,
344 * second element corresponds to y)
345 */
346 convertBoundaryCoordinatesToFeatures(bx, by) {
347 const f1 = this.options.x1 + (bx + 1) / 2 * (this.options.x2 - this.options.x1);
348 const f2 = this.options.y1 + (by + 1) / 2 * (this.options.y2 - this.options.y1);
349
350 return [f1, f2];
351 }
352
353 /**
354 * Calculate canvas coordinates (origin at (0,0)) for a 2-dimensional data point's features
355 *
356 * @param double f1 First feature
357 * @param double f2 Second feature
358 * @return Array[int] Corresponding point in the canvas (first element corresponds to x, second
359 * element corresponds to y)
360 */
361 convertFeaturesToCanvasCoordinates(f1, f2) {
362 const x = (f1 - this.options.x1) / (this.options.x2 - this.options.x1);
363 const y = 1 - ((f2 - this.options.y1) / (this.options.y2 - this.options.y1));
364
365 return [x * this.canvas.width, y * this.canvas.height];
366 }
367
368 /**
369 * Draw a grid on the canvas
370 */
371 drawGrid() {
372 const canvas = this.canvas;
373 const context = canvas.context;
374
375 // Loop over all line offsets
376 for (let i = 1; i < 10; i += 1) {
377 // Horizontal
378 context.beginPath();
379 context.moveTo(0, i / 10 * canvas.height);
380 context.lineTo(canvas.width, i / 10 * canvas.height);
381 context.lineWidth = 1;
382 context.strokeStyle = '#EAEAEA';
383 context.stroke();
384
385 // Vertical
386 context.beginPath();
387 context.moveTo(i / 10 * canvas.width, 0);
388 context.lineTo(i / 10 * canvas.width, canvas.height);
389 context.lineWidth = 1;
390 context.strokeStyle = '#EAEAEA';
391 context.stroke();
392 }
393 }
394
395 /**
396 * Draw the axes on the canvas
397 */
398 drawAxes() {
399 const canvas = this.canvas;
400 const context = canvas.context;
401
402 // Origin coordinates
403 const [originX, originY] = this.convertFeaturesToCanvasCoordinates(0, 0);
404
405 // Horizontal
406 context.beginPath();
407 context.moveTo(0, originY);
408 context.lineTo(canvas.width, originY);
409 context.lineWidth = 2;
410 context.strokeStyle = '#CCC';
411 context.stroke();
412
413 // Vertical
414 context.beginPath();
415 context.moveTo(originX, 0);
416 context.lineTo(originX, canvas.height);
417 context.lineWidth = 2;
418 context.strokeStyle = '#CCC';
419 context.stroke();
420 }
421
422 /**
423 * Draw class boundaries
424 */
425 drawClassBoundaries() {
426 const context = this.canvas.context;
427
428 Object.keys(this.classesBoundaries).forEach((classLabel) => {
429 const classBoundaries = this.classesBoundaries[classLabel];
430
431 // The path delineates the decision region for this class
432 context.beginPath();
433
434 classBoundaries.forEach((classBoundary) => {
435 let firstpoint = true;
436
437 classBoundary.forEach((boundaryPoint) => {
438 const [xx, yy] = this.convertFeaturesToCanvasCoordinates(
439 ...this.convertBoundaryCoordinatesToFeatures(boundaryPoint[0], boundaryPoint[1])
440 );
441
442 if (firstpoint) {
443 firstpoint = false;
444 context.moveTo(xx, yy);
445 } else {
446 context.lineTo(xx, yy);
447 }
448
449 if (Math.abs(boundaryPoint[0]) !== 1 && Math.abs(boundaryPoint[1]) !== 1) {
450 context.fillStyle = this.getClassColor(classLabel);
451 context.fillStyle = '#000';
452 context.globalAlpha = 0.25;
453 // context.fillRect(xx - 1, yy - 1, 2, 2);
454 context.globalAlpha = 1;
455 }
456
457 // context.lineTo(x2, y2);
458 // context.strokeStyle = this.getClassColor(contours[i].k);
459 });
460
461 context.closePath();
462 });
463
464 context.fillStyle = '#5DA5DA';
465 context.strokeStyle = '#5DA5DA';
466 context.fillStyle = this.getClassColor(classLabel);
467 context.strokeStyle = this.getClassColor(classLabel);
468 context.globalAlpha = 0.5;
469 context.fill();
470 context.globalAlpha = 1;
471 // context.stroke();
472 });
473 }
474
475 /**
476 * Get drawing color for a class index
477 *
478 * @param int classIndex Class index
479 * @return string Color in HEX with '#' prefix
480 */
481 getClassColor(classIndex) {
482 const colors = this.getColors();
483 return colors[Object.keys(colors)[parseInt(classIndex, 10)]];
484 }
485
486 /**
487 * Get available drawing colors
488 *
489 * @return Array[string] Colors in HEX with '#' prefix; array keys are color names.
490 */
491 getColors() {
492 return {
493 blue: '#5DA5DA',
494 orange: '#FAA43A',
495 green: '#60BD68',
496 pink: '#F17CB0',
497 brown: '#B2912F',
498 purple: '#B276B2',
499 yellow: '#DECF3F',
500 red: '#F15854',
501 gray: '#4D4D4D',
502 };
503 }
504}
505
506export default Canvas;