Skip to content

Instantly share code, notes, and snippets.

@m4dz
Last active December 16, 2015 13:49
Show Gist options
  • Save m4dz/5444372 to your computer and use it in GitHub Desktop.
Save m4dz/5444372 to your computer and use it in GitHub Desktop.
DOM events mouse style detection in <canvas/> with heatmaps - Generic lib implementation

Problem : you can't attach DOM style mouse events (over, out, click…) on elements drawn onto a element.

Solution : Heatmaps ! When drawing onto the <canvas/>, do the same onto a "hidden" map canvas that will NEVER be inserted into the DOM. Assign random colors to the elements drawn to the map, then add regular DOM events to the visible element. When an event fire up, compare coordinates of the mouse in the <canvas/>, to the map, detect the color to find the element on which the event apply, then fire the custom event stack.

So simple isn't it ? Hack you said ;) ?

Wait… It's just JavaScript !

(function($, undefined) {
var $canvas
, heatmap
, ctx
, WSemicircle
, ESemicircle
, Rect
, $legend = $('.legend');
$canvas = $('<canvas/>').prependTo('#container')
.attr('width', 200)
.attr('height', 200);
ctx = $canvas[0].getContext('2d');
heatmap = new Heatmap($canvas[0]);
function drawSemicircle(startAngle) {
this.beginPath();
this.arc(100, 100, 80, (0.5 + startAngle) * Math.PI, (1.5 + startAngle) * Math.PI, false);
this.closePath();
this.fill();
}
function drawRect() {
this.beginPath();
this.rect(100, 100, 80, 80);
this.closePath();
this.fill();
}
ctx.fillStyle = 'green';
WSemicircle = heatmap.addShape(drawSemicircle, 0);
ESemicircle = heatmap.addShape(drawSemicircle, 1);
ctx.fillStyle = 'red';
Rect = heatmap.addShape(drawRect);
WSemicircle
.on('mouseover', function(e) {
$canvas.css('cursor', 'w-resize');
$legend.html("You're upon the <b>left</b> part of the circle &larr;");
})
.on('mouseout', function(e) {
$canvas.css('cursor', 'auto');
$legend.empty();
})
.on('click', function(e) {
$legend.html("<span class='error'>You've clicked the <b>left</b> part of the circle ;)</span>");
});
ESemicircle
.on('mouseover', function(e) {
$canvas.css('cursor', 'e-resize');
$legend.html("You're upon the <b>right</b> part of the circle &rarr;");
})
.on('mouseout', function(e) {
$canvas.css('cursor', 'auto');
$legend.empty();
});
Rect
.on('mousemove', function(e) {
$legend.html("You're upon the <b>square</b>,<br>with your cursor located at [" + e.pageX + ',' + e.pageY + "]");
})
.on('mouseout', function(e) {
$legend.empty();
});
$('#unbind').on('click', function() {
WSemicircle.off('click');
});
})(jQuery);
//
// Canvas Heatmap
// Version - 0.1
//
// This tiny lib implement a generic heatmap feature for canvas as described in
// this Gist : https://gist.github.com/madsgraphics/4676852.
//
// The purposes of this lib is to bind DOM and custom events on canvas shapes.
//
// by
// MAD <ecrire@madsgraphics.com>
//
// Tri-license - WTFPL | MIT | BSD
//
(function(undefined) {
// EVENT
// -----
//
// Define an Event Object to handle th custom events stacks on shapes
function Event(heatmap) {
// the events stacks
this.stack = {};
// a hover flag to check the mouseover/mouseout
this.isOver = false;
// reference to the parent heatmap
this.heatmap = heatmap;
}
// Bind event with `on` method
Event.prototype.on = function (name, handler) {
var _this = this;
// Create the stack for the event if it doesn't exists
if (this.stack[name] === undefined) {
this.stack[name] = [];
}
// Add handler to stack
this.stack[name].push(handler);
// If heatmap already registered an event listener for this event, exit.
if (this.heatmap.listeners.indexOf(name) !== -1) { return this; }
// Add event listener to DOM canvas element for this event
this.heatmap.canvas.addEventListener(name, function(e) {
_this.heatmap._trigger(e);
}, false);
this.heatmap.listeners.push(name);
// Return `this` event object to permit chaining "a la jQuery"
return this;
};
// Unbind event
Event.prototype.off = function (name, handler) {
// Unregister all events for the event if no handler is specified
if (handler === undefined) {
this.stack[name] = undefined;
}
// or unregister only given handler
else {
for(var _i = 0, _len = this.stack[name].length; _i < _len; _i++) {
if (handler === this.stack[name][_i]) {
this.stack[name][_i] = undefined;
}
}
}
};
// Launch event stack
Event.prototype.fire = function(name, evt) {
// Exit if there's no stack for this event
if (this.stack[name] === undefined) { return; }
// Loop on stack event's handlers and call them
for (var _i = 0, _len = this.stack[name].length; _i < _len; _i++) {
this.stack[name][_i].call(this, evt);
}
};
// HEATMAP
// -------
//
// The heatmap object. Take the DOM canvas element as argument
function Heatmap(el) {
this.canvas = el;
this._init();
}
// _initializer
Heatmap.prototype._init = function () {
// Create a non-DOM map canvas
this.map = document.createElement('canvas');
// set it the same size as the DOM reference canvas
this.map.width = this.canvas.width;
this.map.height = this.canvas.height;
// store contexts for convenience
this.contexts = {
canvas : this.canvas.getContext('2d'),
map : this.map.getContext('2d')
};
// Prepare the shape (for bind events) and listeners references
this.shapes = {};
this.listeners = [];
};
// Add a custom shape the the canvas
//
// It will add the shape both on canvas and on map. It take the drawing
// function as argument.
Heatmap.prototype.addShape = function (drawFunc) {
// Create a random rgb value to fill the shape on the heatmap
var r = Math.floor(Math.random()*256)
, g = Math.floor(Math.random()*256)
, b = Math.floor(Math.random()*256)
// use it as a UID
, uid = r + ':' + g + ':' + b
// extract arguments for later use
, args = Array.prototype.slice.call(arguments, 1);
// Call drawfunc on canvas with potential arguments
drawFunc.apply(this.contexts.canvas, args);
// Then call it again on map after defined the fill color with the
// randomized RGB value.
this.contexts.map.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
drawFunc.apply(this.contexts.map, args);
// Store the shape for later use as an Event instance
// add an empty mousemove handler to detect 'mouseover' and 'mouseout' evt
// and return it
this.shapes[uid] = new Event(this);
this.shapes[uid].on('mousemove', function() { return; });
return this.shapes[uid];
};
// The trigger called to fire events
Heatmap.prototype._trigger = function (e) {
// Detect RGB-UID value under the pointer
var pix = this._getRGB(e.pageX - e.target.offsetLeft, e.pageY - e.target.offsetTop)
, uid;
if (e.type === 'mousemove') {
// on mousemove, first loop on shapes to detect mouseout actions
for (uid in this.shapes) {
if (pix !== uid && this.shapes[uid].isOver) {
this.shapes[uid].isOver = false;
this.shapes[uid].fire('mouseout', e);
}
}
// then call mouseover event on the current hovered shape (if exists)
if (this.shapes[pix] !== undefined && !this.shapes[pix].isOver) {
this.shapes[pix].isOver = true;
this.shapes[pix].fire('mouseover', e);
}
}
// Exit if there's no shape
if (this.shapes[pix] === undefined) { return; }
// call the correponding stack event for the correct shape
this.shapes[pix].fire(e.type, e);
};
// return the current RGB color under the pointer
Heatmap.prototype._getRGB = function (x, y) {
var pixelData = this.contexts.map.getImageData(x, y, 1, 1).data;
return pixelData[0] + ':' + pixelData[1] + ':' + pixelData[2];
};
// Export Heatmap to the global scope
window.Heatmap = Heatmap;
})();
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Canvas Heatmaps!</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="container" class="bordered">
<p class="legend bordered"></p>
<button id="unbind">Unbind click event</button>
</div>
<script src="//code.jquery.com/jquery-1.9.1.js"></script>
<script src="heatmap.js"></script>
<script src="app.js"></script>
</body>
</html>
#container {
width: 200px;
margin-bottom: 10px;
}
.legend {
background-color: #444;
color: #fff;
font-family: Monospace;
}
.bordered {
border: 1px solid #000;
border-radius: 5px;
padding: 5px;
}
.error {
color: yellow;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment