Navigation dynamique avec Backbone.js sur une carte OpenStreetMap

mer. 15 mai 2013
Colombian Training Ship ARC "Gloria"

Il y a quelques semaines, nous avons vu comment créer une page spécifique dans Mezzanine pour afficher une carte grâce à OpenStreetMap. Nous allons poursuivre l'exercice et en faire une application un peu plus puissante. Notre but : permettre à un administrateur de rentrer lui-même différents « lieux » ou points intéressants, pour qu'ils puissent s'afficher sur la carte. Chaque point est cliquable, et dispose de sa propre url. Pour pousser un peu le raffinement, nous utiliserons Backbone.js pour que la navigation soit dynamique, et éviter la nécessité de recharger toute la page à chaque fois.

Des p'tits points… des p'tits points… encore des p'tits points !

Nous allons créer une application Django qui contiendra le code spécifique à la gestion de notre carte. Commençons par le modèle :

# models.py
from django.utils.translation import ugettext_lazy as _
from django.db import models
from mezzanine.core.fields import RichTextField


class PointOfInterest(models.Model):
    name = models.CharField(_('name'), max_length=50)
    description = RichTextField(_('Description'), null=True, blank=True)
    lat = models.FloatField(_('Latitude'))
    lng = models.FloatField(_('Longitude'))
    slug = models.SlugField(_('Slug'))

    class Meta:
        verbose_name = _('Point of interest')
        verbose_name_plural = _('Points of interest')

    def __unicode__(self):
        return self.name

Rien de trés compliqué jusque là ; chaque « lieu » dispose d'un titre, d'une description, et de coordonnées géographiques. L'activation du module d'admin est encore plus simple.

# admin.py
from django.contrib import admin
from city_map.models import PointOfInterest


class PointOfInterestAdmin(admin.ModelAdmin):
    prepopulated_fields = {"slug": ("name",)}


admin.site.register(PointOfInterest, PointOfInterestAdmin)

Configurons maintenant les urls de notre application :

# urls.py
from django.conf.urls import patterns, include, url


urlpatterns = patterns("city_map.views",
    # L'url racine, qui affiche la carte globale
    url('^$', 'map', name='map'),

    # Url spécifique affichant le détail de chaque lieu
    url('^(?P[\w-]+)/$', 'point_of_interest', name='point_of_interest'),

    # Nos markeurs seront disponibles en json
    url('^markers.json$', 'markers_data', name='markers_data'),
)

Les vues correspondantes ne contiennent absolument rien de compliqué.

# views.py
from django.http import HttpResponse
from django.core import serializers
from django.shortcuts import get_object_or_404
from django.utils import simplejson
from mezzanine.utils.views import render
from your_map_app.models import PointOfInterest


def map(request):
    return render(request, 'map.html')

def point_of_interest(request, slug):
    selected_poi = get_object_or_404(PointOfInterest, slug=slug)
    return render(request, 'map.html', {
            'selected_poi': selected_poi,
    })

def markers_data(request):
    pois = PointOfInterest.objects.all().values()
    data = simplejson.dumps(list(pois))
    return HttpResponse(data, mimetype='application/json')

Tout est en place, il ne nous reste plus qu'à récupérer les données json pour les afficher sur la carte. Dans le template écrit la dernière fois, remplacez le bloc extra_js par celui-ci :

// This function is called whenever the map is ready
function citymapInit(map, bounds) {
    $.getJSON('{% url 'markers_data' %}', function(data) {
        $.each(data, function(key, val) {
            var link = getMarkerLink(val);
            L.marker([val['lat'], val['lng']]).addTo(map).bindPopup(link);
        });
    });
}

function getMarkerLink(json) {
    // Returns a correct link to the point of interest url

    var url_root = '{% url 'map' %}';
    var url = url_root + json['slug'] + '/';
    return '' + json['name'] + '';
}

Si tout s'est bien passé, chaque marqueur dispose maintenant d'un lien qui permet d'accéder à l'url du lieu. Pas mal, non ?!

markers_with_leaflet

Un peu de dynamisme avec Backbone.js

Notre carte est bien sympathique, mais elle manque un peu de dynamisme. Chaque clic génère un rechargement complet de la page, ce qui va vite devenir fastidieux. Nous allons donc ajouter une couche de javascript qui va y remédier. Et pas n'importe quel javascript s'il vous plaît ! Nous allons utiliser Backbone.js.

Il fut un temps ou la création d'applications web dynamiques et single page apps se faisait difficilement sans l'apparition de code jquery spaggheti particulièrement dégueulasse. Du js mélangé au html, des données stockées dans le dom, etc. Tout cela n'était pas sans rappeler le bon vieux temps pré-frameworks MVC ou PHP, HTML et requêtes SQL s'imbriquaient lubriquement sans la moindre pudeur.

Heureusement, le monde Javascript a lui aussi gagné en maturité et de (trop ?) nombreux outils sont apparus pour remédier à ce problème. L'un des premiers et plus populaires est Backbone.js, que l'on qualifiera (pour simplifier de manière éhontée) de framework MVC côté client.

Concrètement, Backbone.js fournit les outils pour gérer les données et leurs traitement via leurs propres classes ; il (ou elle ?) offre des vues pour gérer l'UI et rendre des templates à partir des données des modèles ; elle gère les événements pour permettre la synchronisation entre les interactions de l'utilisateur et les modifications des données ; elle propage l'enregistrement des données sur le serveur via des appels ajax à une api json ; elle propose une navigation en se basant sur l'API History. Bref ! Backbone fait pas mal de trucs.

En revanche, Backbone.js est plus une librairie qu'un véritable framework, en ce sens qu'elle n'impose pas de structure à votre application. Le cas échéant, on lui adjoindra une surcouche comme Marionette.js, ou on préférera un vrai framework comme Angular.js.

Notre utilisation de Backbone sera trés simple puisque notre appli sera read only. Mais c'est un bon début pour se mettre le pied à l'étrier.

Du dynamique sans la bolognaise

Reprenons notre template map.html, effaçons notre bloc extra_js, et remplaçons-le par ceci :

// La librairie backbone et sa dépendance
<script src="{{ STATIC_URL }}js/underscore.js"></script>
<script src="{{ STATIC_URL }}js/backbone.js"></script>

// C'est dans ce fichier que nous allons développer notre appli Backbone
<script src="{{ STATIC_URL }}js/map/app.js"></script>

<script type="text/javascript">
// Il nous faudra passer des paramètres de Python à Javascript
App.Config = {
    // L'adresse à laquelle récupérer les marqueurs en json
    markerCollectionUrl: "{% url 'markers_data' %}",

    // L'url courante de la carte
    mapUrl: "{% url 'map' %}"
};

function citymapInit(map, bounds) {
    // Quand la carte est prête, nous lançons l'application
    App.start(map);
}

Notre application Backbone va définir un objet racine App, qui va entre autre contenir les paramètres de configuration, et son point d'entrée sera la fonction start, que nous appelerons pour lancer le schmilblick. Bon, et si nous y jetions y coup d'œil, à cette fameuse application ?

// your_map_app/static/js/map/app.js

var App = {
    Models: {},
    Collections: {},
    Views: {},
    Routers: {},
    Config: {}
};

Nous commençons par définir notre objet App, qui servira de conteneur pour toute l'appli. Backbone nous offre des outils pour déclarer des modèles et des collections, destinées à recevoir des listes de modèles.

// Nous n'aurons pas de traitements à effectuer, aussi
// notre modèle est-il vide
App.Models.Marker = Backbone.Model.extend({});

// L'API Backbone permet, une fois la collection déclarée, de
// l'initialiser à partir de données json sans difficultés
// particulières
App.Collections.MarkerCollection = Backbone.Collection.extend({
    model: App.Models.Marker,
    initialize: function() {
        this.url = App.Config.markerCollectionUrl;
    },
    getBySlug: function(slug) {
        return this.findWhere({ slug: slug });
    }
});

Nous avons défini la partie modèle de notre application. Les classes en question sont bien vides, vu que nous sommes en read only. Nous allons maintenant définir les vues. Dans Backbone, les vues sont des classes chargées d'effectuer des actions (rendre un template, par exemple) en fonction d'un modèle ou d'une collection et des événements qui y sont rattachés.

// MarkerView est une classe à laquelle on passe un marqueur,
// et qui sera chargée de l'afficher sur la carte via
// l'API Leaflet
App.Views.MarkerView = Backbone.View.extend({

    // fonction lancée à l'instanciation de la vue
    initialize: function(options) {

        // L'objet map (API Leaflet) doit être passé en paramètre
        this.map = options.map,

        // Create the marker using leaflet api
        // Notez l'api d'accès aux données du modèle
        this.marker = L.marker([this.model.get('lat'), this.model.get('lng')]);

        // Create the marker popup
        var popup = document.createElement('a');
        popup.href = App.Config.mapUrl + this.model.get('slug') + '/';
        popup.innerHTML = this.model.get('name');

        // We have to bind the event here, because leaflets prevents
        // the click event to bubble outside the map element
        var slug = this.model.get('slug');
        popup.onclick = function(event) {
            event.preventDefault();
            // Lorsque l'internaute clique sur le lien, nous
            // indiquons à Backbone qu'un changement d'url doit être
            // intercepté
            Backbone.history.navigate(slug + '/', true);
        };
        this.marker.bindPopup(popup);

        // Render the marker to the map
        // (Leaflet API)
        this.marker.addTo(this.map);
    }
});

// MapView est la vue principale. Elle va instancier la collection des marqueurs
// et les afficher un par un
App.Views.MapView = Backbone.View.extend({
    initialize: function(options) {
        this.map = options.map;
        this.markers = new App.Collections.MarkerCollection();

        // Nous téléchargeons les données json pour instancier
        // la collection. Dés que c'est fait, on lance la fonction
        // de rendu.
        this.listenTo(this.markers, "reset", this.render);
        this.markers.fetch({ reset: true });
    },
    render: function() {
        this.markers.each(this.addOne, this);
        return this;
    },
    addOne: function(marker) {
        var view = new App.Views.MarkerView({
            model: marker,
            map: this.map
        });
    },
    getMarkerBySlug: function(slug) {
        return this.markers.getBySlug(slug);
    }
});

// Cette vue permettra de mettre à jour la page à partir d'un
// marqueur donné
App.Views.MainView = Backbone.View.extend({
    initialize: function() {
        this.descriptionElement = $('#poi-main');
        this.titleElement = $('h1');
        this.sidebarElement = ('#poi-sidebar');
        // Nous utiliserons un template pour le rendu de la
        // sidebar. Nous le définirons un peu plus tard
        this.sidebarTemplate = _.template($('#poi-sidebar-template').html());
    },
    render: function() {
        this.descriptionElement.html(this.model.get('description'));
        this.titleElement.html(this.model.get('name'));
        this.sidebarElement.html(this.sidebarTemplate(this.model.attributes));
    },
});

Même si l'API utilisée peut dérouter dans un premier temps, le code est relativement simple. Une vue principale, MapView, récupère les données via l'API en instanciant une collection, puis pour chaque marqueur délègue son affichage en instanciant une vue spécifique.

Nous avons notre modèle, nos vues. Il manque… le… le… controleur ! Bravo à ceux qui suivent.

// Dans l'API Backbone, un router permet de déclarer les différentes
// urls disponibles, et de faire correspondre un traitement spécifique
// à chacune d'entre elles
App.Routers.MapRouter = Backbone.Router.extend({
    initialize: function(options) {
        // Nous n'utiliserons que deux urls pour notre appli
        this.route('', 'map');
        this.route(':slug/', 'pointOfInterest');

        // instancions maintenant les vues principales
        this.mapView = new App.Views.MapView({ map: options.map });
        this.mainView = new App.Views.MainView();
    },
    map: function() {
        // La carte est déjà affichée, rien à faire ici
    },
    pointOfInterest: function(slug) {
        // L'utilisateur vient de cliquer sur un lien, il
        // faut afficher le marqueur correspondant

        // Récupérons le marqueur en question, à partir du "slug" passé en url
        var marker = this.mapView.getMarkerBySlug(slug);

        // Il nous suffit de passer le bon marqueur à la vue
        // et de la laisser s'occuper du rendu
        this.mainView.model = marker;
        this.mainView.render();
    }
});

Il manque une chose à notre appli. Notre point d'entrée, la fameuse fonction start. Son seul but est d'initialiser le contrôleur.

App.start = function(map) {
    var mapRouter = new App.Routers.MapRouter({ map: map });
    Backbone.history.start({
        pushState: true,
        root: App.Config.mapUrl,
        silent: true,
    });
};

Tadaaaam ! Le résultat est visible sur ce site de démo, et le code est sur Github. Dans le prochain billet, j'essaierai d'employer encore plus de technos hype d'un coup.