Jump To …
backend.ckan.js backend.couchdb.js backend.csv.js backend.dataproxy.js backend.elasticsearch.js backend.gdocs.js backend.memory.js data.transform.js ecma-fixes.js model.js view.graph.js view.grid.js view.map.js view.multiview.js view.slickgrid.js view.timeline.js view.transform.js widget.facetviewer.js widget.fields.js widget.filtereditor.js widget.pager.js widget.queryeditor.js

# view.map.js

¶

    /*jshint multistr:true */

    this.recline = this.recline || {};
    this.recline.View = this.recline.View || {};

    (function($, my) {

¶

## 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 either via a field with GeoJSON objects or two fields with latitude and longitude coordinates.

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}
      }

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.el = $(this.el);
        this.visible = true;
        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.model.fields.bind('change', function() {
          self._setupGeometryField();
          self.render();
        });

¶

Listen to changes in the records

        this.model.records.bind('add', function(doc){self.redraw('add',doc);});
        this.model.records.bind('change', function(doc){
            self.redraw('remove',doc);
            self.redraw('add',doc);
        });
        this.model.records.bind('remove', function(doc){self.redraw('remove',doc);});
        this.model.records.bind('reset', function(){self.redraw('reset');});

        this.menu = new my.MapMenu({
          model: this.model,
          state: this.state.toJSON()
        });
        this.menu.state.bind('change', function() {
          self.state.set(self.menu.state.toJSON());
          self.redraw();
        });
        this.state.bind('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 (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;
      },

¶

## 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;

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

¶

enable clustering if there is a large number of markers

          var countAfter = 0;
          this.features.eachLayer(function(){countAfter++;});
          var sizeIncreased = countAfter - countBefore > 0;
          if (!this.state.get('cluster') && countAfter > 64 && sizeIncreased) {
            this.state.set({cluster: true});
            return;
          }

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

      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 (key in self.features._layers){
            if (self.features._layers[key].feature.properties.cid == doc.cid){
              self.features.removeLayer(self.features._layers[key]);
            }
          }
        });

      },

¶

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 = parseFloat(parts[0]);
            var lon = parseFloat(parts[1]);
            if (!isNaN(lon) && !isNaN(parseFloat(lat))) {
              return {
                "type": "Point",
                "coordinates": [lon, lat]
              };
            } else {
              return null;
            }
          } else if (value && value.slice) {

¶

[ 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'));
          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 MapQuest based on OpenStreetMap.

      _setupMap: function(){
        var self = this;
        this.map = new L.Map(this.$map.get(0));

        var mapUrl = "http://otile{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);

        this.features = new L.GeoJSON(null,{
            pointToLayer: function (feature, latlng) {
              var marker = new L.marker(latlng);
              marker.bindPopup(feature.properties.popupContent);
              self.markers.addLayer(marker);
              return marker;
            }
        });

        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> \
                <option value=""></option> \
                {{#fields}} \
                <option value="{{id}}">{{label}}</option> \
                {{/fields}} \
                </select> \
              </div> \
              <label>Longitude field</label> \
              <div class="input editor-lon-field"> \
                <select> \
                <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> \
                <option value=""></option> \
                {{#fields}} \
                <option value="{{id}}">{{label}}</option> \
                {{/fields}} \
                </select> \
              </div> \
            </div> \
          </div> \
          <div class="editor-buttons"> \
            <button class="btn 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" /> \
          </div> \
        </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;
        this.el = $(this.el);
        _.bindAll(this, 'render');
        this.model.fields.bind('change', this.render);
        this.state = new recline.Model.ObjectState(options.state);
        this.state.bind('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;
        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);