• Jump To … +
    backend.dataproxy.js backend.memory.js ecma-fixes.js model.js view.flot.js view.graph.js view.grid.js view.map.js view.multiview.js view.slickgrid.js view.timeline.js widget.facetviewer.js widget.fields.js widget.filtereditor.js widget.pager.js widget.queryeditor.js widget.valuefilter.js
  • view.map.js

  • ¶
    /*jshint multistr:true */
    
    this.recline = this.recline || {};
    this.recline.View = this.recline.View || {};
    
    (function($, my) {
      "use strict";
  • ¶

    Map view for a Dataset using Leaflet mapping library.

    This view allows to plot gereferenced records on a map. The location information can be provided in 2 ways:

    1. Via a single field. This field must be either a geo_point or GeoJSON object
    2. Via two fields with latitude and longitude coordinates.

    Which fields in the data these correspond to can be configured via the state (and are guessed if no info is provided).

    Initialization arguments are as standard for Dataset Views. State object may have the following (optional) configuration options:

      {
        // geomField if specified will be used in preference to lat/lon
        geomField: {id of field containing geometry in the dataset}
        lonField: {id of field containing longitude in the dataset}
        latField: {id of field containing latitude in the dataset}
        autoZoom: true,
        // use cluster support
        // cluster: true = always on
        // cluster: false = always off
        cluster: false
      }
    

    Useful attributes to know about (if e.g. customizing)

    • map: the Leaflet map (L.Map)
    • features: Leaflet GeoJSON layer containing all the features (L.GeoJSON)
    my.Map = Backbone.View.extend({
      template: ' \
        <div class="recline-map"> \
          <div class="panel map"></div> \
        </div> \
    ',
  • ¶

    These are the default (case-insensitive) names of field that are used if found. If not found, the user will need to define the fields via the editor.

      latitudeFieldNames: ['lat','latitude'],
      longitudeFieldNames: ['lon','longitude'],
      geometryFieldNames: ['geojson', 'geom','the_geom','geometry','spatial','location', 'geo', 'lonlat'],
    
      initialize: function(options) {
        var self = this;
        this.visible = this.$el.is(':visible');
        this.mapReady = false;
  • ¶

    this will be the Leaflet L.Map object (setup below)

        this.map = null;
    
        var stateData = _.extend({
            geomField: null,
            lonField: null,
            latField: null,
            autoZoom: true,
            cluster: false
          },
          options.state
        );
        this.state = new recline.Model.ObjectState(stateData);
    
        this._clusterOptions = {
          zoomToBoundsOnClick: true,
  • ¶

    disableClusteringAtZoom: 10,

          maxClusterRadius: 80,
          singleMarkerMode: false,
          skipDuplicateAddTesting: true,
          animateAddingMarkers: false
        };
  • ¶

    Listen to changes in the fields

        this.listenTo(this.model.fields, 'change', function() {
          self._setupGeometryField();
          self.render();
        });
  • ¶

    Listen to changes in the records

        this.listenTo(this.model.records, 'add', function(doc){self.redraw('add',doc);});
        this.listenTo(this.model.records, 'change', function(doc){
            self.redraw('remove',doc);
            self.redraw('add',doc);
        });
        this.listenTo(this.model.records, 'remove', function(doc){self.redraw('remove',doc);});
        this.listenTo(this.model.records, 'reset', function(){self.redraw('reset');});
    
        this.menu = new my.MapMenu({
          model: this.model,
          state: this.state.toJSON()
        });
        this.listenTo(this.menu.state, 'change', function() {
          self.state.set(self.menu.state.toJSON());
          self.redraw();
        });
        this.listenTo(this.state, 'change', function() {
          self.redraw();
        });
        this.elSidebar = this.menu.$el;
      },
  • ¶

    Customization Functions

    The following methods are designed for overriding in order to customize behaviour

  • ¶

    infobox

    Function to create infoboxes used in popups. The default behaviour is very simple and just lists all attributes.

    Users should override this function to customize behaviour i.e.

    view = new View({...});
    view.infobox = function(record) {
      ...
    }
    
      infobox: function(record) {
        var html = '';
        for (var key in record.attributes){
          if (!(this.state.get('geomField') && key == this.state.get('geomField'))){
            html += '<div><strong>' + key + '</strong>: '+ record.attributes[key] + '</div>';
          }
        }
        return html;
      },
  • ¶

    Options to use for the Leaflet GeoJSON layer See also http://leaflet.cloudmade.com/examples/geojson.html

    e.g.

    pointToLayer: function(feature, latLng)
    onEachFeature: function(feature, layer)
    

    See defaults for examples

      geoJsonLayerOptions: {
  • ¶

    pointToLayer function to use when creating points

    Default behaviour shown here is to create a marker using the popupContent set on the feature properties (created via infobox function during feature generation)

    NB: inside pointToLayer this will be set to point to this map view instance (which allows e.g. this.markers to work in this default case)

        pointToLayer: function (feature, latlng) {
          var marker = new L.Marker(latlng);
          marker.bindPopup(feature.properties.popupContent);
  • ¶

    this is for cluster case

          this.markers.addLayer(marker);
          return marker;
        },
  • ¶

    onEachFeature default which adds popup in

        onEachFeature: function(feature, layer) {
          if (feature.properties && feature.properties.popupContent) {
            layer.bindPopup(feature.properties.popupContent);
          }
        }
      },
  • ¶

    END: Customization section

  • ¶
  • ¶

    Public: Adds the necessary elements to the page.

    Also sets up the editor fields and the map if necessary.

      render: function() {
        var self = this;
        var htmls = Mustache.render(this.template, this.model.toTemplateJSON());
        this.$el.html(htmls);
        this.$map = this.$el.find('.panel.map');
        this.redraw();
        return this;
      },
  • ¶

    Public: Redraws the features on the map according to the action provided

    Actions can be:

    • reset: Clear all features
    • add: Add one or n features (records)
    • remove: Remove one or n features (records)
    • refresh: Clear existing features and add all current records
      redraw: function(action, doc){
        var self = this;
        action = action || 'refresh';
  • ¶

    try to set things up if not already

        if (!self._geomReady()){
          self._setupGeometryField();
        }
        if (!self.mapReady){
          self._setupMap();
        }
    
        if (this._geomReady() && this.mapReady){
  • ¶

    removing ad re-adding the layer enables faster bulk loading

          this.map.removeLayer(this.features);
          this.map.removeLayer(this.markers);
    
          var countBefore = 0;
          this.features.eachLayer(function(){countBefore++;});
    
          if (action == 'refresh' || action == 'reset') {
            this.features.clearLayers();
  • ¶

    recreate cluster group because of issues with clearLayer

            this.map.removeLayer(this.markers);
            this.markers = new L.MarkerClusterGroup(this._clusterOptions);
            this._add(this.model.records.models);
          } else if (action == 'add' && doc){
            this._add(doc);
          } else if (action == 'remove' && doc){
            this._remove(doc);
          }
  • ¶

    this must come before zooming! if not: errors when using e.g. circle markers like “Cannot call method ‘project’ of undefined”

          if (this.state.get('cluster')) {
            this.map.addLayer(this.markers);
          } else {
            this.map.addLayer(this.features);
          }
    
          if (this.state.get('autoZoom')){
            if (this.visible){
              this._zoomToFeatures();
            } else {
              this._zoomPending = true;
            }
          }
        }
      },
    
      show: function() {
  • ¶

    If the div was hidden, Leaflet needs to recalculate some sizes to display properly

        if (this.map){
          this.map.invalidateSize();
          if (this._zoomPending && this.state.get('autoZoom')) {
            this._zoomToFeatures();
            this._zoomPending = false;
          }
        }
        this.visible = true;
      },
    
      hide: function() {
        this.visible = false;
      },
    
      _geomReady: function() {
        return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
      },
  • ¶

    Private: Add one or n features to the map

    For each record passed, a GeoJSON geometry will be extracted and added to the features layer. If an exception is thrown, the process will be stopped and an error notification shown.

    Each feature will have a popup associated with all the record fields.

      _add: function(docs){
        var self = this;
    
        if (!(docs instanceof Array)) docs = [docs];
    
        var count = 0;
        var wrongSoFar = 0;
        _.every(docs, function(doc){
          count += 1;
          var feature = self._getGeometryFromRecord(doc);
          if (typeof feature === 'undefined' || feature === null){
  • ¶

    Empty field

            return true;
          } else if (feature instanceof Object){
            feature.properties = {
              popupContent: self.infobox(doc),
  • ¶

    Add a reference to the model id, which will allow us to link this Leaflet layer to a Recline doc

              cid: doc.cid
            };
    
            try {
              self.features.addData(feature);
            } catch (except) {
              wrongSoFar += 1;
              var msg = 'Wrong geometry value';
              if (except.message) msg += ' (' + except.message + ')';
              if (wrongSoFar <= 10) {
                self.trigger('recline:flash', {message: msg, category:'error'});
              }
            }
          } else {
            wrongSoFar += 1;
            if (wrongSoFar <= 10) {
              self.trigger('recline:flash', {message: 'Wrong geometry value', category:'error'});
            }
          }
          return true;
        });
      },
  • ¶

    Private: Remove one or n features from the map

      _remove: function(docs){
    
        var self = this;
    
        if (!(docs instanceof Array)) docs = [docs];
    
        _.each(docs,function(doc){
          for (var key in self.features._layers){
            if (self.features._layers[key].feature.geometry.properties.cid == doc.cid){
              self.features.removeLayer(self.features._layers[key]);
            }
          }
        });
    
      },
  • ¶

    Private: convert DMS coordinates to decimal

    north and east are positive, south and west are negative

      _parseCoordinateString: function(coord){
        if (typeof(coord) != 'string') {
          return(parseFloat(coord));
        }
        var dms = coord.split(/[^-?\.\d\w]+/);
        var deg = 0; var m = 0;
        var toDeg = [1, 60, 3600]; // conversion factors for Deg, min, sec
        var i;
        for (i = 0; i < dms.length; ++i) {
            if (isNaN(parseFloat(dms[i]))) {
              continue;
            }
            deg += parseFloat(dms[i]) / toDeg[m];
            m += 1;
        }
        if (coord.match(/[SW]/)) {
              deg = -1*deg;
        }
        return(deg);
      },
  • ¶

    Private: Return a GeoJSON geomtry extracted from the record fields

      _getGeometryFromRecord: function(doc){
        if (this.state.get('geomField')){
          var value = doc.get(this.state.get('geomField'));
          if (typeof(value) === 'string'){
  • ¶

    We may have a GeoJSON string representation

            try {
              value = $.parseJSON(value);
            } catch(e) {}
          }
          if (typeof(value) === 'string') {
            value = value.replace('(', '').replace(')', '');
            var parts = value.split(',');
            var lat = this._parseCoordinateString(parts[0]);
            var lon = this._parseCoordinateString(parts[1]);
    
            if (!isNaN(lon) && !isNaN(parseFloat(lat))) {
              return {
                "type": "Point",
                "coordinates": [lon, lat]
              };
            } else {
              return null;
            }
          } else if (value && _.isArray(value)) {
  • ¶

    [ lon, lat ]

            return {
              "type": "Point",
              "coordinates": [value[0], value[1]]
            };
          } else if (value && value.lat) {
  • ¶

    of form { lat: …, lon: …}

            return {
              "type": "Point",
              "coordinates": [value.lon || value.lng, value.lat]
            };
          }
  • ¶

    We o/w assume that contents of the field are a valid GeoJSON object

          return value;
        } else if (this.state.get('lonField') && this.state.get('latField')){
  • ¶

    We’ll create a GeoJSON like point object from the two lat/lon fields

          var lon = doc.get(this.state.get('lonField'));
          var lat = doc.get(this.state.get('latField'));
          lon = this._parseCoordinateString(lon);
          lat = this._parseCoordinateString(lat);
    
          if (!isNaN(parseFloat(lon)) && !isNaN(parseFloat(lat))) {
            return {
              type: 'Point',
              coordinates: [lon,lat]
            };
          }
        }
        return null;
      },
  • ¶

    Private: Check if there is a field with GeoJSON geometries or alternatively, two fields with lat/lon values.

    If not found, the user can define them via the UI form.

      _setupGeometryField: function(){
  • ¶

    should not overwrite if we have already set this (e.g. explicitly via state)

        if (!this._geomReady()) {
          this.state.set({
            geomField: this._checkField(this.geometryFieldNames),
            latField: this._checkField(this.latitudeFieldNames),
            lonField: this._checkField(this.longitudeFieldNames)
          });
          this.menu.state.set(this.state.toJSON());
        }
      },
  • ¶

    Private: Check if a field in the current model exists in the provided list of names.

      _checkField: function(fieldNames){
        var field;
        var modelFieldNames = this.model.fields.pluck('id');
        for (var i = 0; i < fieldNames.length; i++){
          for (var j = 0; j < modelFieldNames.length; j++){
            if (modelFieldNames[j].toLowerCase() == fieldNames[i].toLowerCase())
              return modelFieldNames[j];
          }
        }
        return null;
      },
  • ¶

    Private: Zoom to map to current features extent if any, or to the full extent if none.

      _zoomToFeatures: function(){
        var bounds = this.features.getBounds();
        if (bounds && bounds.getNorthEast() && bounds.getSouthWest()){
          this.map.fitBounds(bounds);
        } else {
          this.map.setView([0, 0], 2);
        }
      },
  • ¶

    Private: Sets up the Leaflet map control and the features layer.

    The map uses a base layer from OpenStreetMap based on OpenStreetMap data.

      _setupMap: function(){
        var self = this;
        this.map = new L.Map(this.$map.get(0));
    
        var mapUrl = "http://otile{s}-s.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.png";
        var osmAttribution = 'Map data &copy; 2011 OpenStreetMap contributors, Tiles Courtesy of <a href="http://www.mapquest.com/" target="_blank">MapQuest</a> <img src="http://developer.mapquest.com/content/osm/mq_logo.png">';
        var bg = new L.TileLayer(mapUrl, {maxZoom: 18, attribution: osmAttribution ,subdomains: '1234'});
        this.map.addLayer(bg);
    
        this.markers = new L.MarkerClusterGroup(this._clusterOptions);
  • ¶

    rebind this (as needed in e.g. default case above)

        this.geoJsonLayerOptions.pointToLayer =  _.bind(
            this.geoJsonLayerOptions.pointToLayer,
            this);
        this.features = new L.GeoJSON(null, this.geoJsonLayerOptions);
    
        this.map.setView([0, 0], 2);
    
        this.mapReady = true;
      },
  • ¶

    Private: Helper function to select an option from a select list

      _selectOption: function(id,value){
        var options = $('.' + id + ' > select > option');
        if (options){
          options.each(function(opt){
            if (this.value == value) {
              $(this).attr('selected','selected');
              return false;
            }
          });
        }
      }
    });
    
    my.MapMenu = Backbone.View.extend({
      className: 'editor',
    
      template: ' \
        <form class="form-stacked"> \
          <div class="clearfix"> \
            <div class="editor-field-type"> \
                <label class="radio"> \
                  <input type="radio" id="editor-field-type-latlon" name="editor-field-type" value="latlon" checked="checked"/> \
                  Latitude / Longitude fields</label> \
                <label class="radio"> \
                  <input type="radio" id="editor-field-type-geom" name="editor-field-type" value="geom" /> \
                  GeoJSON field</label> \
            </div> \
            <div class="editor-field-type-latlon"> \
              <label>Latitude field</label> \
              <div class="input editor-lat-field"> \
                <select class="form-control"> \
                <option value=""></option> \
                {{#fields}} \
                <option value="{{id}}">{{label}}</option> \
                {{/fields}} \
                </select> \
              </div> \
              <label>Longitude field</label> \
              <div class="input editor-lon-field"> \
                <select class="form-control"> \
                <option value=""></option> \
                {{#fields}} \
                <option value="{{id}}">{{label}}</option> \
                {{/fields}} \
                </select> \
              </div> \
            </div> \
            <div class="editor-field-type-geom" style="display:none"> \
              <label>Geometry field (GeoJSON)</label> \
              <div class="input editor-geom-field"> \
                <select class="form-control"> \
                <option value=""></option> \
                {{#fields}} \
                <option value="{{id}}">{{label}}</option> \
                {{/fields}} \
                </select> \
              </div> \
            </div> \
          </div> \
          <div class="editor-buttons"> \
            <button class="btn btn-default editor-update-map">Update</button> \
          </div> \
          <div class="editor-options" > \
            <label class="checkbox"> \
              <input type="checkbox" id="editor-auto-zoom" value="autozoom" checked="checked" /> \
              Auto zoom to features</label> \
            <label class="checkbox"> \
              <input type="checkbox" id="editor-cluster" value="cluster"/> \
              Cluster markers</label> \
          </div> \
          <input type="hidden" class="editor-id" value="map-1" /> \
        </form> \
      ',
  • ¶

    Define here events for UI elements

      events: {
        'click .editor-update-map': 'onEditorSubmit',
        'change .editor-field-type': 'onFieldTypeChange',
        'click #editor-auto-zoom': 'onAutoZoomChange',
        'click #editor-cluster': 'onClusteringChange'
      },
    
      initialize: function(options) {
        var self = this;
        _.bindAll(this, 'render');
        this.listenTo(this.model.fields, 'change', this.render);
        this.state = new recline.Model.ObjectState(options.state);
        this.listenTo(this.state, 'change', this.render);
        this.render();
      },
  • ¶

    Public: Adds the necessary elements to the page.

    Also sets up the editor fields and the map if necessary.

      render: function() {
        var self = this;
        var htmls = Mustache.render(this.template, this.model.toTemplateJSON());
        this.$el.html(htmls);
    
        if (this._geomReady() && this.model.fields.length){
          if (this.state.get('geomField')){
            this._selectOption('editor-geom-field',this.state.get('geomField'));
            this.$el.find('#editor-field-type-geom').attr('checked','checked').change();
          } else{
            this._selectOption('editor-lon-field',this.state.get('lonField'));
            this._selectOption('editor-lat-field',this.state.get('latField'));
            this.$el.find('#editor-field-type-latlon').attr('checked','checked').change();
          }
        }
        if (this.state.get('autoZoom')) {
          this.$el.find('#editor-auto-zoom').attr('checked', 'checked');
        } else {
          this.$el.find('#editor-auto-zoom').removeAttr('checked');
        }
        if (this.state.get('cluster')) {
          this.$el.find('#editor-cluster').attr('checked', 'checked');
        } else {
          this.$el.find('#editor-cluster').removeAttr('checked');
        }
        return this;
      },
    
      _geomReady: function() {
        return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
      },
  • ¶

    UI Event handlers

  • ¶

    Public: Update map with user options

    Right now the only configurable option is what field(s) contains the location information.

      onEditorSubmit: function(e){
        e.preventDefault();
        if (this.$el.find('#editor-field-type-geom').attr('checked')){
          this.state.set({
            geomField: this.$el.find('.editor-geom-field > select > option:selected').val(),
            lonField: null,
            latField: null
          });
        } else {
          this.state.set({
            geomField: null,
            lonField: this.$el.find('.editor-lon-field > select > option:selected').val(),
            latField: this.$el.find('.editor-lat-field > select > option:selected').val()
          });
        }
        return false;
      },
  • ¶

    Public: Shows the relevant select lists depending on the location field type selected.

      onFieldTypeChange: function(e){
        if (e.target.value == 'geom'){
            this.$el.find('.editor-field-type-geom').show();
            this.$el.find('.editor-field-type-latlon').hide();
        } else {
            this.$el.find('.editor-field-type-geom').hide();
            this.$el.find('.editor-field-type-latlon').show();
        }
      },
    
      onAutoZoomChange: function(e){
        this.state.set({autoZoom: !this.state.get('autoZoom')});
      },
    
      onClusteringChange: function(e){
        this.state.set({cluster: !this.state.get('cluster')});
      },
  • ¶

    Private: Helper function to select an option from a select list

      _selectOption: function(id,value){
        var options = this.$el.find('.' + id + ' > select > option');
        if (options){
          options.each(function(opt){
            if (this.value == value) {
              $(this).attr('selected','selected');
              return false;
            }
          });
        }
      }
    });
    
    })(jQuery, recline.View);