Carte SVG en AngularJS

En quelques années AngularJS est devenu le framework javascript de référence. L’apprentissage de ce framework est rapide, mais la création de nouveaux composants via des directives est plus complexe.

Nous allons voir comment développer un composant de favoris sur les villes et départements de France en directive Angular. L’utilisateur doit pouvoir sélectionner des départements sur une carte de France puis sélectionner des villes dans ceux-ci pour les ajouter à une liste de favoris.

Quelques considérations spécifiques au projet : le client travaille uniquement en France métropolitaine et considère la Corse comme un seul département (toutes mes excuses).

De plus, certains départements n’ont aucune ville à mettre en favori, et doivent donc être cachés.

La structure de données

Nos données de départ sont fournies via un service REST sous forme de liste de départements avec leurs villes. Un champ “favori” booléen indique la présence ou l’absence de la ville dans les favoris, et un département est donc favori si au moins une de ses villes est favorite :

[
{ nom : "Calvados",
  code : "14",
  villes : [
  { nom : "Caen",
    code : "14000",
    favori : true
        },
  // ... autres villes du département
  ],
  // ... autres départements
}
]

Le but est de présenter de manière visuelle simple cette structure au client.

Recherche de solutions

Règle n°0 du bon codeur : ne pas réinventer la roue. La première étape a été d’étudier l’existant.

Historiquement, nous avons la carte HTML pur avec une image et une liste d’aires circulaires, rectangulaires ou polygonales définies dans une map.

Pour ceux qui veulent approfondir, un tutorial datant de 2000.

Nous avons ensuite la carte en Flash qui est snobbée par les possesseurs d’iDevices, et enfin nous avons le HTML5 avec les 2 technologies de dessin que sont le SVG et le Canvas incrustés dans la page Web.

La solution Canvas présente plusieurs problèmes :

  • Les interactions avec la souris doivent être programmées à la main à partir des coordonnées des clics : pas possible de mettre un onclick simple sur un sous-élément.
  • Si on modifie le canvas, il faut tout redessiner.
  • C’est du bitmap donc il y a des risques d’avoir des éléments pixellisés en cas de zoom ou d’écran avec résolution plus grande que prévue.
  • AngularJS n’aime pas qu’on manipule le DOM ou qu’on déclenche des évènements dans son dos, l’interaction nécessite alors de nombreuses rustines.

Nous partons donc sur SVG qui, permettant de dessiner sous forme de tags HTML, s’intègre bien avec Angular.

SVG : Scalable Vector Graphics

N’étant pas graphiste ni géographe, il faut trouver les coordonnées des départements pour l’affichage. Wiki Commons vient à la rescousse avec une flopée de cartes de France en SVG avec différents niveaux de détail.

Laissons de côté la carte de répartition du lynx ou la carte de densité de chou par région et prenons une carte la plus épurée possible, celle des préfectures.

Il faut récupérer le fichier via le lien (la carte affichée est une PNG générée avec le SVG)

Le fichier SVG est constitué de plusieurs séries de path. En SVG, un path représente un chemin qui, fermé, peut correspondre à un contour ou une aire.

Dans notre projet, la position de la préfecture sur la carte n’est pas désirable, ni la vue des autres pays.

Retirons l’Angleterre, l’Espagne, le logo, l’échelle et autres éléments superflus d’un coup de Ctrl-X dans le code source ce qui nous donne un code de la forme :

<?xml version="1.0" encoding="UTF-8" standalone="no"?>

<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
   width="520" height="550">
  <g id="layer" style="display:inline">
    <path
       class="departement"
       d="m 446.28822,489.78463 -0.1875 [... Un tas de coordonnées pas franchement intéressantes à lire...], -1.0625 -2.1875,-0.40625 -1.5625,0 z"
       id="departement14"/>
    <path .. [Un tas de départements de même structure que le premier… ] />
<path class=”separateurCorse” d="m 432.74935,564.84924 0,-92.63099 91.33847,-52.73429"
       id="path5398"/>
  </g>
</svg>

Nous pouvons pousser le vice jusqu’à éliminer les décimales superflues : 5 décimales pour du SVG c’est au moins 3 de trop.

Petite révision de nettoyage au RegExp

Révisons nos RegExp : il faut extraire les chiffres “.xxyyy” du texte et ne garder que “.xx” :

Un chiffre c’est \d, donc la transformation se fait ainsi :

replace \.(\d{2})(\d*) => .$1

Le fichier de la carte perd ainsi 25% de sa taille.

Code couleur

Après s’être amusé avec toutes les possibilités qu’offre le SVG présentées sur w3schools, nous reprenons la charte graphique client et retenons juste 3 styles :

  • département hors périmètre : marron
  • département favori : bleu
  • département non favori : rouge.

A ce stade, je me rends compte que parmi les méta-clients (clients du client), il y a probablement des daltoniens (8% des hommes et 0,45% des femmes, les prenez-vous en compte sur vos sites ?) : Votre site vu par un daltonien.

Un tour sur cette URL permet de vérifier que mes 3 couleurs ne seront pas confondues par les différents types de daltoniens.

Interactions

Si vous testez les clics sur la carte Wiki Commons, par exemple sur la Manche et le Calvados, vous verrez que les zones cliquables sont un mélange de zones circulaires et de polygones moins précis que les départements. Comme les clients de notre client sont tatillons, nous allons faire du pixel-perfect.

Insérons notre carte dans une page gérée par un contrôleur AngularJS, puis faisons un test sur le Calvados en ajoutant un ng-click et une ng-class.

<path      class="departement"       ng-click="alert('yo');"

ng-class=”favori”       d="m 20...0 z"       id="departement14"/>

Aucun des deux ne marche. La raison est simple : le SVG fixe les données dès que le navigateur lit le contenu du <path>, avant que AngularJS ne bidouille le ng-click pour qu’il déclenche son contenu lors d’un clic. A la lecture de ce <path>, il y a donc un élément ng-click inutilisable, on affiche le contenu et basta.

Il faut donc changer de stratégie et ne plus avoir des path mais des directives AngularJS qui regénèreront le <path>.

Création de la directive departement

Appelons la directive de manière originale “departement” et voyons ce qu’on veut avoir côté HTML :

Grâce au système de directive, Chaque élément <departement> s’occupera d’un département et aura donc son propre petit scope AngularJS avec ses fonctions et ses données. Que mettons nous dans ce micro-scope ?

  • id représente l’identifiant du département, ici 14 pour Calvados.
  • textx et texty les coordonnées du numéro à afficher.
  • is-selected et select-departement sont les objets fonctions Javascript à donner à la directive
  • contour est l’attribut “d” du path qui contient les informations de dessin.

Voici la directive côté AngularJS :

monModule.directive('departement', function($compile) {
    return {
        restrict: 'E',
        scope: {
            id : '@',
            contour : '@',
            textx : '@',
            texty : '@',
            selectDepartementHandler: "&mySelectDepartementFunction",
            isSelectedHandler: "&myIsSelectedFunction",
        },
        link : function(scope, tElement) {
                var xmlns = "http://www.w3.org/2000/svg";
                var gElem = document.createElementNS(xmlns, 'g');
                var pathElem = document.createElementNS(xmlns, 'path');
                var textElem = document.createElementNS(xmlns, 'text');
                pathElem.setAttribute('id', scope.id);

                if (scope.isSelectedHandler()(scope.id)) {
                    pathElem.setAttribute('class', "selected");
                } else {
                    pathElem.setAttribute('class', "selectable");
                }

                pathElem.setAttribute('d', scope.contour);
                textElem.setAttribute('x', scope.textx);
                textElem.setAttribute('y', scope.texty);
                textElem.textContent=scope.id;
                tElement.replaceWith(gElem);
                // on met le text d'abord pour pas qu'il soit cliquable
                gElem.appendChild(textElem);
                // le path avec fill-opacity à 40%
                gElem.appendChild(pathElem);

                $(pathElem).click(function() {
                   var res = scope.selectDepartementHandler()(scope.id);
                   if (res) {
                     pathElem.setAttribute('class', 'selected');
                   } else {
                     pathElem.setAttribute('class', 'selectable');
                   }
                });
                $compile(tElement.contents())(scope);
            }
    }
});

directive angular
Décortiquons-la :

monModule.directive('departement', function($compile) {

return {

Nous créons la directive ”département” dans le module monModule.

restrict: 'E',

La directive concerne un élément HTML “E” : <departement ... /> et non un attribut “A” : <path departement ... /> ou une classe “C” <path class=”departement”>

scope: {
           id : '@',
           contour : '@',
           textx : '@',
           texty : '@',
           selectDepartementHandler: "&mySelectDepartementFunction",
           isSelectedHandler: "&myIsSelectedFunction",
       },

Chaque <departement> aura son scope isolé. Isolé signifie qu’il n’hérite pas du scope parent. Celui-ci contiendra l’id, le contour, le textx, le texty passés en attribut de même nom.

Si nous avions voulu que la modification à l’intérieur du <departement> se propage à l’objet passé nous aurions mis = mais là les valeurs sont fixes donc on utilise @ qui est un binding simple.

Le nom n’est pas spécifié : ce sera donc le même que celui de l’attribut. scope.textx prendra la valeur de l’attribut textx.

Pour les fonctions, nous spécifions le nom et nous passons l’objet fonction avec & qui permet d’exécuter une expression dans le contexte du scope parent. Ainsi myIsSelectedFunction n’est pas la chaine de caractère “myIsSelectedFunction” mais la fonction myIsSelectedFunction définie dans le scope du contrôleur.

Note : j’ai passé plusieurs fois beaucoup de temps à me creuser la tête sur des éléments de scope non remplis. Afin de vous éviter les mêmes interrogations, je mets le passage suivant en évidence :

Si vous nommez vos variables en camelcase, n’oubliez pas que HTML est insensible à la casse et qu’AngularJS transforme les noms en camel-case.

La transformation se fait en 2 temps : on retire le suffixe x- ou data- et on transforme les {:-_} en passage en majuscule.

Ainsi si vous nommez votre attribut data-select-departement, AngularJS le liera dans le scope sous le nom selectDepartement.

link : function(scope, tElement) {
               var xmlns = "http://www.w3.org/2000/svg";
               var gElem = document.createElementNS(xmlns, 'g');
               var pathElem = document.createElementNS(xmlns, 'path');
               var textElem = document.createElementNS(xmlns, 'text');
               pathElem.setAttribute('id', scope.id);
               if (scope.isSelectedHandler()(scope.id)) {
                   pathElem.setAttribute('class', "selected");
               } else {
                   pathElem.setAttribute('class', "selectable");
               }
               pathElem.setAttribute('d', scope.contour);
               textElem.setAttribute('x', scope.textx);
               textElem.setAttribute('y', scope.texty);
               textElem.textContent=scope.id;
               tElement.replaceWith(gElem);
               gElem.appendChild(textElem);
               gElem.appendChild(pathElem);
               $(pathElem).click(function() {
                  var res = scope.selectDepartementHandler()(scope.id);
                  if (res) {
                    pathElem.setAttribute('class', 'selected');
                  } else {
                    pathElem.setAttribute('class', 'selectable');
                  }
               });
               $compile(tElement.contents())(scope);
           }
   }

Enfin le link permet de manipuler le DOM de la directive.

Ici nous créons un élément SVG groupe <G> qui contiendra 2 éléments SVG : le numéro du département dans un <TEXT> et le dessin du département dans un <PATH>.

Notes : Si le texte est par dessus le path, le département n’est plus cliquable quand la souris passe sur le texte. Celui-ci est donc placé sous le path, et le path est mis en translucide.

Explication du scope.selectDepartementHandler()(scope.id) :

La fonction selectDepartementHandler doit être exécutée pour accéder à la fonction elle-même, à laquelle on passe ensuite les paramètres, c’est pourquoi il faut exécuter scope.selectDepartementHandler()(scope.id) et non scope.selectDepartementHandler(scope.id).

Retour à la carte

Notre directive est désormais fonctionnelle mais nous devons utiliser cette directive dans la carte. Le besoin fonctionnel limitant les départements pouvant être favoris, nous avons les départements immuables en <path>, les départements favorisables en <departement> et enfin le trait de séparation de la Corse (à cause du fromage) en <path>.

<svg id="map" width="492px" height="543px" version="1.1" >
           <g>
                       <path data-num="89" d="M310.475"></path>
<....>
                       <path data-num="90" d="M4125"></path>
                       <departement textx="219" texty="302" id="87" is-selected="isSelected" select-departement="selectDepartement" contour="M231.53125"></departement>
<...>
                       <departement textx="421" texty="165" id="88" is-selected="isSelected" select-departement="selectDepartement" contour="M451,141.93751.9375"></departement>
                       <path class="separator" d="M424,541.25 L424,471 L488.25,429" id="corsica_separating_line"></path>
                   </g>
</svg>

CSS

Le style de la carte n’a pas beaucoup d’intérêt si ce n’est qu’il faut faire attention à l’opacité : sous Chrome, “0.4” ou “40%” sont indifféremment acceptés alors que Firefox n’acceptera que le “0.4”.

#map {
 max-width: 100%;
 max-height: 100%;
}

#map path {
 fill: #e0e0c4;
}

#map path.selectable {
 fill: #ff9966;
 fill-opacity: 0.4;
 stroke: #ff6666;
 stroke-width: 0.4;
 transition: fill 0.2s, stroke 0.3s;
}

#map path.selected {
 fill: #6699ff;
 fill-opacity: 0.4;
 stroke: #6666ff;
 stroke-width: 0.4;
 transition: fill 0.2s, stroke 0.3s;
}

#map path.selectable:hover {
 fill: #ffccff;
 stroke: #ffccff;
}

#map path.selected:hover {
 fill: #ffccff;
 stroke: #ffccff;
}

#map text {
 fill: black;
 font-style: bold;
}

#map .separator {
 stroke: #ccc;
 fill: none;
 stroke-width: 1.0;
}

#map .separator:hover {
 stroke: #ccc;
 fill: none;
}

#status {
 fill: red;
}

Désormais nous avons une belle carte :

image00

Gestion des villes en favoris

A côté de la carte, nous allons utiliser la directive accordion gracieusement offerte par Angular-UI pour présenter un arbre dépliable de départements et leurs villes sélectionnables ou désélectionnables.

<div class="col-md-4">
  <h3>Départements favoris</h3>
  <accordion close-others="true">

    <accordion-group ng-repeat="departement in favoris" heading="{{departement.code}} - {{departement.nom}}" is-open="status.isFirstOpen">
      <div ng-repeat="ville in departement.villes">
        <input type="checkbox" class="ng-valid" ng-model="ville.favori">
        <span ng-click="selectVille(departement.code, ville.code);" class="label {{ville.favori ? 'label-info' : 'label-danger'}}">
        {{ville.code}} - {{ville.nom}}
        </span>
      </div>
    </accordion-group>

  </accordion>
</div>

<div class="col-md-8">
  <svg … La carte />
</div>

La modale

Comme cela est destiné à être réutilisé dans plusieurs formulaires, il faut mettre cet écran de sélection dans une modale.

En utilisant la directive modale de Angular-UI cela donne côté template HTML :

<div class="modal-header">
  <button type="button" class="close" ng-click="cancel()"><span aria-hidden="true">×</span><span class="sr-only">Close</span></button>
  <h4 class="modal-title">Départements <small>Sélectionnez vos départements</small></h4>
</div>

<div class="modal-body">
  <div class="row">
  <!-- L’écran précédent avec les départements et la carte →
</div>

<div class="modal-footer">
  <button class="btn btn-primary" ng-click="ok()">OK</button>
  <button class="btn btn-warning" ng-click="cancel()">Cancel</button>
</div>

</div>

et côté AngularJS :

infonetWebApp.factory('SelectDptsModal', ['$modal',
   function ($modal) {
       return {
           openModal : function(selectedDepartementsIds, successFn){
               var modalInstance = $modal.open({
                  templateUrl: 'views/modals/modalSelectDepartements.html',
                  controller: 'ModalSelectDepartementsCtrl',
                  size : 'lg',
                  resolve : {
                              selectedDepartementsIds : function() {
                                   return selectedDepartementsIds;
                              }
                  }
              });
              modalInstance.result.then(successFn);
           }
      };
   }]);

La mise en modale permet en plus d’accepter ou d’annuler les modifications de la liste, avec $scope.favoris contenant la liste des favoris, la fonction ouvrant le popup de modale pour les modifier est :

$scope.openSelectDptsModal = function() {
           SelectDptsModal.openModal(angular.copy(favoris), function (nouveauxFavoris) {
               $log.info('Favoris mis à jour');
                $scope.favoris = nouveauxFavoris;
                $route.reload();
              });
}

Cette modale peut être ensuite réutilisée pour sélectionner des villes et départements à plusieurs endroits d’un site.

Conclusion

En conclusion, les fonds de carte SVG libres peuvent être facilement manipulés et transformés en directive pour faire des cartes dynamiques.

Le code complet est disponible sur JSFiddle.