Gérer vos évènements utilisateur dans canvas

Canvas nous offre des possibilités de conception d’interfaces entièrement nouvelles : animations, interfaces, fractales… Tout est possible, tout est imaginable. Mais canvas reste une interface relativement bas-niveau, vue comme une boite noire par le DOM. Face à cette situation, il fallait trouver une solution pour gérer les DOM events. Elle s’appelle Heatmaps.

C’était il y a quelques semaines, à l’occasion d’un nouveau projet pour un client. L’objectif était la réalisation d’une interface complexe, très largement animée, avec des effets de transition riches. Très rapidement, nous avons écarté la solution CSS Transitions / Animations : trop coûteuse, trop lourde à mettre en œuvre pour se tourner plus volontiers vers <canvas></canvas>. Avec ça, on devrait pouvoir y arriver.

Le POC

Faire du <canvas></canvas> nu, from scratch, c’est comme de vouloir construire un bateau avec une boite d’allumettes en guise de matière première : ça va être long, ça va être difficile, et ça ne sera peut-être pas très droit à l’arrivée.

Heureusement il existe des librairies au-dessus de <canvas></canvas>, qui commencent à arriver à maturité. Notre choix se porte sur Kinetic. Si vous ne connaissez pas encore cette formidable librairie, je pense qu’on vous en parlera rapidement. Elle ajoute un niveau d’abstraction à <canvas></canvas> pour facilement dessiner des formes, les animer, leur attacher des événements… Un bel outil à ajouter dans votre trousseau de développeur front-end.

Ce projet d’interface est ambitieux, Kinetic va se révéler un outil précieux. Mais il reste un écueil de taille : l’interface doit être pleinement compatible et fonctionnelle sous IE8. Et là, c’est un peu comme si le monde s’ouvrait sous vos pieds. Demander à IE8 de supporter <canvas></canvas>, c’est comme de demander à la tortue de la fable de voler pour aller plus vite. Elle ne vole pas la tortue, c’est comme ça. Il va falloir trouver une alternative. Il n’y en a pas beaucoup pour émuler un fonctionnement de <canvas></canvas> : exCanvas et FlashCanvas. La première est trop instable. La seconde permet de remplacer <canvas></canvas> par un flash et c’est une librairie JS qui va faire le bridge. La dernière version supporte 70 % des fonctionnalités de <canvas></canvas>. Les quelques informations glanées ici et là sont encourageantes, croisons les doigts, en avant pour le grand saut…

Plus dure sera la chute…

Nous commençons donc le travail de conception, nous utilisons intensivement les capacités de Kinetic, l’interface prend forme. Nous transposons ensuite la réalisation dans IE8 en utilisant le support FlashCanvas. Et là, nouveau drame : Kinetic est visiblement incompatible avec FlashCanvas dans l’usage que nous en faisons. Dans notre cas, impossible de le faire fonctionner, malgré ce que nous avions pu trouver ici et là comme ressources nous indiquant le contraire… Considérable déception, nous sommes tristesse. Pas le choix donc, il nous faut re-concevoir l’interface pour IE8 en nous passant du support de Kinetic et utiliser directement les fonctionnalités <canvas></canvas> supportées par FlashCanvas.

C’est alors — Dieu, que cette histoire est riche en rebondissements — qu’une triste réalité nous frappe : <canvas></canvas> ne sait pas attacher d’événements DOM aux éléments qu’il dessine. Kinetic le faisait aisément pour nous, pas besoin de s’en soucier outre-mesure. Oui mais là, on est coincés : il va falloir trouver un moyen de gérer différents événements (click, mousemove, mouseover…) sans aucun support natif disponible.

La solution de secours : Heatmaps !

Il faut que je vous mette en garde dès maintenant : cette solution tient du hack. C’est une bidouille comme on en fait depuis plus de 20 ans sur le web. Mais c’est une bidouille qui :

  • a la bonté de fonctionner correctement ;
  • a le bon goût d’être élégante à défaut d’être standard.

Vous pouvez assez facilement en trouver les théories ici ou là sur le net, mais son implémentation peut sembler complexe : c’est là tout l’objectif de cet article, vous proposer une implémentation qualitative et détaillée du concept.

Démonstration

Reprenons le problème : j’ai une interface qui, dans le cas de ma démo, va être relativement simple. Elle va se composer d’un cercle qui devra réagir au survol de la souris pour changer son curseur, selon que celui-ci survole l’hémicycle droit ou l’hémicycle gauche. En surimpression en bas à droite, un carré recouvrira un quart de la zone et réagira au survol en remplissant une balise indiquant les coordonnées courantes du curseur. De plus, l’hémicycle gauche devra modifier le message de la légende au clic (en changeant sa couleur). Rien de très exceptionnel, mais ces quelques éléments nous contraignent à gérer différents DOM events. Si vous voulez vous faire une idée du résultat, la démo est ci-dessous :

Premier objectif : détecter la zone de survol

Avant de pouvoir gérer les événements, il va nous falloir détecter sur quel éléments ceux-ci doivent s’appliquer. De quoi disposons-nous avec <canvas></canvas> pour détecter l’élément au dessus duquel notre curseur se trouve positionné ? À vrai dire, pas grand-chose : <canvas></canvas> propose une unique méthode isPointInPath() qui renvoie un booléen selon que le point aux coordonnées indiquées est dans le path en cours de dessin.

Il est facile de trouver la position du curseur sur le canvas :

function pointerOffset(e) {
var offset = this.offset() ;
return [e.pageX - offset.left, e.pageY - offset.top] ;
}

$('#canvas').on('mousemove', function(e) { mousePos = pointerOffset.call(this, e) ; }) ;

En attachant un événement mousemove sur la balise #canvas, à chaque déplacement de la souris, on détecte sa position sur la page (e.page<em>X|Y</em>) auquel on soustrait la position de la balise sur la page (offset) pour obtenir les coordonnées du curseur à l’intérieur du canvas.
On peut donc passer ces coordonnées à la méthode isPointInPath(). Problème : cette méthode ne fonctionne qu’à partir du moment où vous dessinez un élément sur votre canvas. Vous voilà donc obligé de redessiner l’intégralité de votre <canvas></canvas> à chaque mouvement du curseur pour vérifier si le path que vous êtes en train de dessiner se trouve sous le curseur. Absurde et suicidaire en terme de performance…

Deuxième solution : utiliser une carte de correspondance de zones

isPointInPath() ne peut donc pas nous aider sur ce coup. La solution va venir des heatmaps, que j’appelle joliment « cartes de correspondances de zones » (c’est mignon, non ?). Il s’agit de dessiner une fois pour toute votre <canvas></canvas> et de créer dans le même temps un double (maléfique), un second <canvas></canvas> identique au premier dans ses formes mais qui utilisera des zones de couleurs aléatoirement définies pour remplir ses paths. Ce jumeau multicolore ne sera jamais injecté dans le DOM. Il va uniquement servir de référence. À chaque déplacement du curseur, il suffira de regarder, sur la heatmap la couleur se trouvant aux coordonnées correspondant au curseur sur le <canvas></canvas> principal. On obtient de cette façon la zone survolée. Simple, efficace, rapide, performant.

Implémentons ce concept de façon élégante pour pouvoir réutiliser par la suite cet outil de façon agnostique (Don’t Repeat Yourself et Keep It Smart and Simple)…

Définition des canvas

Première étape, créons les canvas à utiliser. Il nous en faut donc deux : un premier pour l’interface à afficher, un second pour la heatmap. Nous allons également devoir stocker les contextes des <canvas></canvas> pour y accéder facilement, et les éléments que nous allons dessiner((De façon générale, pensez à systématiquement mettre en cache les éléments dont vous aller vous servir fréquemment. Vous y gagnerez en performance, en stabilité, et limiterez les consommations de mémoire excessives et inutiles.)).

var $canvas = { $('<canvas></canvas>').prependTo('body')
, $map = $('<canvas></canvas>')
, CTX = {}
, ELEMENTS = {} ;
}

Toutes les actions affectant notre <canvas></canvas> principal doivent être répercutées sur la heatmap associée. Nous allons donc concevoir différentes fonctions pour les manipuler simultanément. Commençons par les initialiser : nous leur fixons des dimensions et stockons les contextes.

function _initCanvas($el) {
$el.attr('width', 200).attr('height', 200) ;
return $el[0].getContext('2d') ;
}

Au passage, créons une fonction d’init qui va se charger de démarrer l’ensemble  :

function init() {
CTX.canvas = _initCanvas($canvas) ;
CTX.map = _initCanvas($map) ;
}

Dessine moi un mouton

Nos canvas sont donc disponibles, ainsi que leurs contextes. Ajoutons une méthode qui permettra de dessiner des formes (shapes) sur nos canvas :

function addElement(drawFunc) {
var r = Math.floor(Math.random()*256)
, g = Math.floor(Math.random()*256)
, b = Math.floor(Math.random()*256)
, uid = r + ' :' + g + ' :' + b
, args = Array.prototype.slice.call(arguments, 1) ;
drawFunc.apply(CTX.canvas, args) ;

CTX.map.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')' ;
drawFunc.apply(CTX.map, args) ;
}

Nous commençons par créer un identifiant construit sous la forme d’une chaîne RGB. Nous allons en faire un double usage : identifier unitairement un élément sur le <canvas></canvas>, et remplir la zone correspondante sur la heatmap avec une couleur générée aléatoirement.
Notre méthode addElement prend donc en argument une méthode permettant de dessiner une forme et l’applique d’abord sur notre $canvas principal, puis sur notre $map en ayant pris soin de modifier préalablement la couleur de remplissage avec la couleur RGB générée. La fonction de dessin appelée étant la même, les deux zones sur les deux <canvas></canvas> correspondront à l’identique.

Petite astuce ici : la signature de la fonction addElement est de la forme addElement(drawFunc [, drawFunc_Arg1 [, drawFunc_Arg2 [, …]]]]). Les arguments devant être passés à la fonction de dessin le sont au moment où l’on applique la méthode sur le canvas : drawFunc.apply(CTX.canvas, Array.prototype.slice.call(arguments, 1)) ;

De cette façon, les arguments sont passés directement à drawFunc (moins le premier argument, qui est drawFunc lui-même), et this dans la méthode devient le contexte du canvas sur lequel nous sommes en train d’agir.

Dessinons maintenant nos éléments (2 demi-cercles et un carré) en définissant des méthodes de dessin simple :

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() ;
}

Puis utilisons-les pour placer nos éléments après avoir initialisé les canvas :

var WSemicircle
, ESemicircle
, Rect ;

// Initialisation des <canvas></canvas>
init() ;

// Définition de la couleur des demi-cercles sur le canvas principal
CTX.canvas.fillStyle = 'green' ;
// Dessinons deux demi-cercles pour obtenir un cercle complet
// Notez que le deuxième argument est l'angle de départ (en radiant)
// qui va être passé en paramètre à la fonction `drawSemicircle()`.
WSemicircle = addElement(drawSemicircle, 0),
ESemicircle = addElement(drawSemicircle, 1) ;

// Changeons la couleur du canvas principal pour dessiner le carré
CTX.canvas.fillStyle = 'red' ;
Rect = addElement(drawRect) ;

Magie ! Nous obtenons un <canvas></canvas> dans notre page, qui nous présente bien un cercle vert surmonté d’un carré rouge. Et nous avons également un second canvas qui n’apparaît pas dans la page (nous ne l’avons pas injecté dans le DOM) mais qui présente lui trois couleurs différentes pour chacun de ses éléments((Si vous voulez voir à quoi ça ressemble, allez-y, injectez-le dans la page. Rainbow power et poneys garantis !)).

Je vous l’accorde, comme ça, ce n’est pas très spectaculaire. Attachons-nous maintenant à définir des événements sur ces éléments…

Gestion des piles d’événements

Pour gérer les piles d’événements, nous allons implémenter un design pattern pub-sub simplifié. Nous allons créer un objet JS capable de stocker des événements dans une pile, et de déclencher ces événements lorsque nous appellerons un événement donné((jQuery propose une implémentation très élégante du modèle Pub-Sub avec sa solution de Callbacks. Vous pouvez très bien vous en servir mais, dans le cas présent, j’ai voulu l’implémenter manuellement pour détailler correctement le fonctionnement des piles d’événements (et puis y a pas que jQuery dans la vie, hein).)).

Commençons par définir notre classe. Nos objets Event auront deux propriétés : la stack des événements, et un flag isOver permettant de marquer l’élément comme en cours de survol.

function Event() {
// La pile d'événements
this.eventsStack = { ;

// le flag de traçage du survol
this.isOver = false ;
}
}

Ajoutons lui une méthode on() permettant d’attacher (bind) un événement à notre objet, c’est-à-dire d’ajouter une entrée dans la stack d’événements :

Event.prototype.on = function (eventName, callback) {
// On ajoute une entrée correspondant au type d'événement dans notre
// stack si celle-ci n'existe pas encore
if (this.eventsStack[eventName] === undefined)
this.eventsStack[eventName] = [] ;

// Ajoutons maintenant la fonction à appeler lors du déclenchement (fire)
// de l'événement
this.eventsStack[eventName].push(callback) ;

// Retournons l'objet Event courant pour permettre un chaînage
// "à la jQuery" (comme disent les anglais)
return this ;
}

Nous avons également besoin d’une méthode pour déclencher la pile d’événements :

Event.prototype.fire = function(eventName, evt) {
// Early-return : on sort de la méthode s'il n'y a aucun événement à
// déclencher
if (this.eventsStack[eventName] === undefined) return ;

// On appelle nos fonctions attachés à l'événement au sein d'une boucle
// for parcourant itérativement notre pile d'événement
for (var _i = 0, _l = this.eventsStack[eventName].length ; _i < _l ; _i++) {
this.eventsStack[eventName][_i].call(this, evt) ;
} ;
}

Nous avons à présent un système d’événements prêt à fonctionner. Il faut les attacher aux éléments lorsqu’ils sont dessinés sur le <canvas></canvas> principal. Modifions donc notre fonction addElement pour lui ajouter à la toute fin :

function addElement(drawFunc) {
[…]

// On crée un nouvel objet Event pour l'élément que nous venons
// de dessiner. Nous le stockons avec l'identifiant rgb-uid associé
// dans un hash pour le garder sous la main, et nous le retournons pour
// qu'il puisse être disponible pour y attacher des événements via `on`.
return ELEMENTS[uid] = new Event() ;
}

Attache-moi !

Nous disposons à présent d’un système d’événements prêt à recevoir nos instructions. Allons-y, attachons, mes bons !

Souvenez-vous, nous avons créé trois éléments : WSemicircle, ESemicircle et Rect qui correspondent à nos trois formes. Depuis la dernière modification de la méthode addElement, ces variables contiennent désormais un gestionnaire d’événements associé à l’élément dessiné par son rgb-uid. Nous pouvons donc directement attacher nos événements grâce à la méthode on de notre objet Event.

var $legend = $('.legend') ;

WSemicircle
.on('mouseover', function(e) {
$canvas.css('cursor', 'w-resize') ;
$legend.html("You're upon the <b>left</b> part of the circle ←") ;
})
.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 →") ;
})
.on('mouseout', function(e) {
$canvas.css('cursor', 'auto') ;
$legend.empty() ;
}) ;

Rect
.on('mousemove', function(e) {
$legend.html("You're upon the <b>square</b>,
with your cursor located at [" + e.pageX + ',' + e.pageY + "]") ;
})
.on('mouseout', function(e) {
$legend.empty() ;
}) ;

Formidable ! Nous avons défini des événements de type onClick, onMouseover, onMousemove… Mais pour le moment, rien ne nous permet de les déclencher. C’est la dernière partie qu’il nous reste à implémenter.

Fire !

Tâchons d’être pragmatiques (nous nous en sommes plutôt bien sortis jusqu’ici). Nous disposons d’éléments dessinés dans une balise <canvas></canvas>. Ces éléments ne peuvent pas recevoir de DOM events, étant donné qu’ils ne font pas partie du DOM. En revanche, la balise <canvas></canvas> dans laquelle ils sont placés constitue un nœud DOM bien valide. C’est donc elle qui va recevoir les événements et les faire descendre jusqu’aux éléments dessinés.

Premier objectif : il nous faut détecter au-dessus de quel élément se trouve notre curseur pour savoir quelle pile d’événement appeler. C’est ici que rentre en jeu notre heatmap : celle-ci étant le double parfait de notre canvas principal (à la couleur près), c’est elle que nous allons interroger. L’objectif est ici d’obtenir la couleur sous le pointeur au moment où nous lançons le callback d’événement. Cette couleur (RGB) nous servant également d’identifiant (UID), nous saurons quelle pile appeler.

Écrivons donc une fonction capable de nous retourner la couleur du pixel situé sous le curseur (c’est-à-dire aux coordonnées du curseur) sur la heatmap. <canvas></canvas> nous propose une méthode simple, getImageData(x, y, width, height) qui prend en paramètres les coordonnées du point de départ et la dimension de la zone à analyser. Ici, nous n’avons besoin que de la zone située sous le curseur, soit 1px * 1px.

// La fonction utilise this comme défini au contexte canvas à observer
// (nous lui passerons celui de la heatmap), et les coordonnées.
function getUID(x, y) {
var pixelData = this.getImageData(x, y, 1, 1).data ;
// retourne une chaîne formatée comme l'identifiant RGB-UID
return pixelData[0] + ' :' + pixelData[1] + ' :' + pixelData[2] ;
}

Maintenant que nous sommes capable de détecter l’élément que nous survolons avec le curseur, et que nous obtenons son RGB-UID, il ne nous reste plus qu’à appeler la pile d’événement associée si elle existe. C’est la balise <canvas></canvas> parente qui va s’en charger. Pour nous simplifier la vie((Vous avez vu comme j’aime ces petites fonctions pratiques qui font qu’on ne tape jamais deux fois la même chose ?)), nous définissons une méthode d’appel globale (un trigger) que nous allons par la suite attacher à l’ensemble des événements à supporter sur le nœud <canvas></canvas>.

function eventsTrigger(e) {
// nous stockons dans pix l'UID de l'élément actuellement survolé
var pix = getUID.apply(CTX.map, pointerOffset.call($canvas, e))
, eventName = e.type
, uid ;

// Cas particulier : nous utilisons `mousemove` pour gérer également
// les événements mouseover et mouseout.
if (eventName === 'mousemove') {
// Nous commençons par boucler sur l'ensemble des éléments dessinés
for (uid in ELEMENTS) {
// si l'élément n'est PAS sous le curseur ET que son flag
// `isOver` est à true, alors nous déclenchons l'événement
// `mouseout`
if (pix !== uid && ELEMENTS[uid].isOver) {
ELEMENTS[uid].isOver = false ;
ELEMENTS[uid].fire('mouseout', e) ;
}

// Nous vérifions ensuite si le flag de l'élément survolé est à false.
// Si oui, nous déclenchons `mouseover` et passons le flag à true.
if (!ELEMENTS[pix].isOver) {
ELEMENTS[pix].isOver = true ;
ELEMENTS[pix].fire('mouseover', e) ;
}
}

// Cas général ensuite, nous déclenchons l'événement associé
ELEMENTS[pix].fire(eventName, e) ;
}

// Nous attachons ensuite à tous les événements que nous souhaitons
// supporter sur `$canvas` la fonction de trigger. Au passage, nous
// isolons les événements dans des namespaces spécifiques pour ne
// pas polluer le scope global et facilement les détacher si besoin.
$canvas.on('mousemove.heatmap, click.heatmap', eventsTrigger) ;

Terminé ! Notre nœud DOM <canvas></canvas> fait désormais redescendre (un reverse bubbling dirons-nous) les événements à ses éléments dessinés. Nous disposons là d’un système pleinement fonctionnel. Je vous propose un petit café pour savourer la conclusion à ce pas à pas.

It’s just JavaScript

Tout ça pouvait sembler extrêmement complexe au premier abord, et la notion de heatmap n’est pas forcément simple à comprendre à la première lecture. Mais en découpant intelligemment notre code, nous avons successivement conçu((Pour les curieux, l’intégralité de l’implémentation décrite ici est disponible à cette adresse : https://gist.github.com/madsgraphics/4676852 ; ainsi qu’une autre version plus aboutie sous forme de librairie framework-agnostic : https://gist.github.com/madsgraphics/5444372)) :

  • Un système de dessin dans <canvas></canvas> en doublon : le nœud principal dans le DOM, et la heatmap avec une génération à la volée d’identifiants basés sur des couleurs aléatoires ;
  • Un système de gestion d’événements sous forme de callbacks de type Pub-Sub, simple et élégant ;
  • Un système d’appel et de bubbling intelligent des événements au sein de ces stacks d’événement, qui s’avère suffisamment léger pour ne pas plomber les performances des interactions utilisateur au sein du DOM.

Tout ça en utilisant un peu d’astuce et la puissance de JavaScript puisque, finalement, ce n’est que du JavaScript.

Pour conclure l’anecdote, cette implémentation que nous venons de réaliser ensemble est celle qui nous a permis de supporter l’intégralité notre interface dans IE8 avec l’aide de FlashCanvas.

Malgré son caractère quelque peu à la marge (ce n’est pas le genre d’implémentation que l’on réalise tous les jours), tout ceci constitue un excellent exercice d’architecture et de modularisation du code. J’espère que vous aurez pu en tirez quelque bénéfice et vous amuser autant que j’ai pris plaisir à le concevoir.

Une petite note avant de terminer : même si toute l’implémentation décrite ici est fonctionnelle et efficace, elle ne constitue pas encore une vraie librairie pour gérer des heatmaps en canvas de façon autonome. Il faudrait pour ça encapsuler toute la logique dans un objet JS, en n’exposant que les méthodes nécessaires (comme addElement()). C’est un bon exercice que je vous laisse réaliser. Pour les plus pressés, l’implémentation finale sous la forme d’une lib générique est disponible ici :).

À vous de jouer maintenant !

2 commentaires

  1. Bonjour,
    Pourquoi kinetic plutôt que jcanvas?

  2. Matthias Dugué

    Bonne question… A l’époque où il a fallu faire un choix, jCanvas ne disposait pas d’une grande visibilité, et Kinetic était très largement vantée par les têtes de pont de l’HTML5. C’est très probablement l’une des raisons principales à cette décision :).

Les commentaires sont désormais fermés.