vendredi 25 janvier 2013

Google Chart Tools avec AngularJS

Pouvoir inclure un graphique dans une application AngularJS par la simple utilisation d'une directive, l'idée est séduisante. Et bien sûr, pour respecter la philosophie du framework, le graphique doit se mettre à jour quand les données changent.



Voici un exemple de directive qui permet exactement ça, pour un graphique de type camembert, en l'occurrence le “Pie Chart” de Google Chart Tools. L'utilisation est toute simple :

<pie-chart data="chartData" title="My Daily Activities" 
           width="500" height="350"
           select="selectRow(selectedRowIndex)"></pie-chart>

Dans les attributs, on fournit simplement les données (ici "chartData", c'est le nom d'une propriété du scope, comme avec ng-bind), le titre du graphique, ses dimensions, et éventuellement une expression qui est exécutée lorsque l'utilisateur sélectionne une portion du camembert.



Cliquez ici pour ouvrir ce premier exemple sur Plunker. Contrairement aux articles précédents, je n'ai pas mis cet exemple sur jsFiddle, car celui-ci entrait en conflit avec les scripts de Google Chart Tools. Sur Plunker (qui d'ailleurs est écrit avec AngularJS), il n'y a pas de problème ; en local non plus bien sûr.

Voyons d'un peu plus près comment ça marche. Dans le fichier app.js, on initialise AngularJS manuellement, en appelant angular.bootstrap(), pour que l'initialisation ait lieu seulement lorsque le chargement de la bibliothèque Google Chart Tools est terminé :

google.setOnLoadCallback(function() {
  angular.bootstrap(document.body, ['app']);
});
google.load('visualization', '1', {packages: ['corechart']});


Le scope isolé de la directive

Mais passons à la directive pieChart elle-même. Elle définit un scope isolé, contenant des propriétés qui correspondent aux cinq attributs possibles de l'élément HTML :

    scope: {
      title:    '@title',
      width:    '@width',
      height:   '@height',
      data:     '=data',
      selectFn: '&select'
    },

C'est un exemple intéressant, car on y trouve les 3 façons de publier des propriétés dans le scope isolé d'une directive en fonction des valeurs des attributs de l'éléments HTML. Les propriétés title, width et heigth, sont spécifiées par '@', ce qui crée une propriété égale à la valeur de l'attribut html associé (on peut d'ailleurs omettre le nom de l'attribut s'il correspond au nom de la propriété). La valeur de cet attribut peut être une chaîne de caractères en dur, comme c'est le cas dans ce premier exemple. Mais elle peut aussi être partiellement ou totalement évaluée par AngularJS si elle contient une expression encadrée par {{...}}, et dans ce cas la propriété est recalculée lorsque l'expression incluse dans l'attribut change de valeur. Vous l'aurez deviné, c'est en fait un binding automatique géré par AngularJS.

Vous pouvez ouvrir ici un second exemple sur Plunker, avec exactement la même directive, mais où les attributs title, width et height sont alimentés par des propriétés du scope (pas le scope isolé de la directive, le scope parent, c'est-à-dire le scope dans lequel est placé le graphique), ce qui permet d'en changer la valeur dans la page web :

<pie-chart data="chartData" title="{{chartTitle}}" 
           width="{{chartWidth}}" height="{{chartHeight}}"
           select="selectRow(selectedRowIndex)"></pie-chart>

La propriété data est publiée dans le scope isolé de la directive en la spécifiant avec un '=' suivi du nom de l'attribut HTML, ce qui publie simplement la propriété du scope parent dont le nom est passé dans l'attribut. Dans l'exemple, avec data="chartData", ça publie dans le scope isolé une propriété data égale à la propriété chartData du scope parent. Donc c'est un attribut qui s'utilise sans doubles accolades, comme ng-bind ou ng-model.

La dernière propriété du scope isolé, selectFn, est spécifiée avec un '&' suivi du nom de l'attribut. AngularJS publie comme propriété une fonction qui évalue l'expression passée dans l'attribut en question. Donc notre propriété selectFn sera une fonction générée par Angular qui évaluera l'expression fournie, ou qui n'évaluera rien si l'attribut select n'est pas présent. L'attribut select peut ainsi contenir n'importe quelle expression valide du langage d'expression d'AngularJS, comme dans un ng-click. Ça peut être un appel de fonction comme dans l'exemple, ou encore l'affectation d'une propriété du scope parent du style "selectedRow = selectedRowIndex".

Mais d'où sort ce selectedRowIndex ? Il est fournit lors de l'exécution de cette fonction selectFn générée par AngularJS :

$scope.selectFn({selectedRowIndex: selectedItem.row});

Ça signifie que l'attribut select peut contenir n'importe quelle expression portant sur des propriétés ou fonctions du scope parent, et dans cette expression, selectRowIndex sera alimenté avec l'index sélectionné.

La fonction de dessin du graphique

A la fin de la directive, on définit une fonction draw() chargée de dessiner le graphique, en lui fournissant les données, et des options comme le titre et les dimensions.

Cette fonction est appelée une première fois systématiquement. Mais il y a aussi une série de quatre $watch qui la rappellent en cas de modification détectée par AngularJS sur les données du graphique, la valeur du titre, la largeur ou la hauteur. C'est ce qui permet de voir le graphique se modifier si on change certaines données dans le premier exemple, ou également le titre ou les dimensions dans le second exemple.

Le $watch correspondant aux données a une particularité, le troisième paramètre égal à true dit à AngularJS de ne pas simplement surveiller la référence à l'objet, mais toutes ses propriétés, c'est un $watch en profondeur :

      $scope.$watch('data', function() {
        draw();
      }, true); // true is for deep object equality checking

Si l'on écrivait juste une fonction dessinant le graphique, déclenchée sur ces quatre $watch, ça marcherait bien sûr, mais ça ne serait pas optimal. Chaque $watch est surveillé par AngularJS en conservant l'ancienne valeur, et en lui comparant la nouvelle. Mais la première fois, il n'y a pas d'ancienne valeur connue, et du coup chaque $watch est déclenché lors du premier $digest (la phase où AngularJS vérifie tous les $watch). Du coup, on aurait systématiquement un premier dessin du graphique déclenché par l'appel en dur de la fonction draw(), immédiatement suivi de quatre autres déclenchés par les quatre $watch. Dessiner cinq fois le même graphique alors que rien n'a été modifié, ce n'est pas très satisfaisant, même si ça va trop vite pour qu'on puisse le voir.

C'est pour ça que j'ai différé tout le contenu de la fonction draw() en utilisant le service $timeout pour qu'il s'exécute après la fin de la phase $digest, et surtout en le conditionnant sur une propriété draw.triggered. Cette propriété est ajoutée à l'objet JavaScript qui n'est autre que la fonction draw elle-même, et elle est alimentée à true lorsque le traitement différé par $timeout a été déclenché. Du coup si cette propriété est déjà égale à true, on évite de le déclencher une seconde fois, ce qui fait que le dessin proprement dit du graphique ne se fera qu'une fois pour une même phase $digest. Bien sûr on remet la propriété à false lorsque s'effectue le traitement différé, pour en permettre à nouveau le déclenchement lors d'une phase $digest ultérieure, si l'une des valeurs surveillées par les $watch a été modifiée.

Gestion de la sélection sur le graphique

L'utilisateur de la page web peut sélectionner une des portions du camembert, et Google Chart Tools permet d'enregistrer un listener qui sera déclenché lors de cette sélection :

      // Chart selection handler
      google.visualization.events.addListener(chart, 'select', function () {
        var selectedItem = chart.getSelection()[0];
        if (selectedItem) {
          $scope.$apply(function () {
            $scope.selectFn({selectedRowIndex: selectedItem.row});
          });
        }
      });

Ce que fait notre directive dans ce code, c'est qu'elle appelle la fameuse fonction selectFn générée dans le contexte isolé par AngularJS, et dont l'exécution évalue l'expression passée dans l'attribut select. C'est ainsi que dans les deux exemples ça appelle la fonction selectRow(selectedRowIndex) publiée dans son scope par le contrôleur, où selectedRowIndex est alimenté par l'index de la sélection. Visuellement ça met un fond rouge à la ligne correspondante dans le tableau des données, simplement en renvoyant une classe CSS différente pour la ligne sélectionnée via une fonction du scope.

On pourrait de la même façon traiter d'autres évènements susceptibles d'être déclenchés par ce graphique en camembert, comme onmouseover et onmouseout qui correspondent au fait de passer la souris au-dessus des portions du camembert.

Voilà pour cet exemple de directive intégrant un graphique de la bibliothèque Google Chart Tools à AngularJS, qui devient ainsi très pratique à utiliser. On peut bien sûr créer des directives similaires pour les autres graphiques de Google Chart Tools. Il y aura parfois de petites différences correspondant aux particularités de chaque graphique, par exemple le “Line Chart” prend plusieurs séries de valeurs, et la sélection ne correspond plus simplement à un index, mais à un index à l'intérieur d'une série.

2 commentaires:

  1. Bonjour !

    Je me demandais, c'est quelle license les chart Google ? Je n'ai pas trouvé l'info sur la toile...

    RépondreSupprimer
  2. Bonjour,

    Apparemment Google publie ça comme une API et non comme un outil open source.

    Les "Terms of Service" se trouvent ici :
    https://developers.google.com/chart/terms

    RépondreSupprimer