/*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](http://geojson.org) 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: 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: ' \
\
\
\ ', // 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 = 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.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 += '
' + key + ': '+ record.attributes[key] + '
'; } } return html; }, // Options to use for the [Leaflet GeoJSON layer](http://leaflet.cloudmade.com/reference.html#geojson) // See also // // 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); } // 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; } // 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.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 && _.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')); 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](http://www.mapquest.com) based // on [OpenStreetMap](http://openstreetmap.org). // _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 © 2011 OpenStreetMap contributors, Tiles Courtesy of MapQuest '; 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: ' \
\
\
\ \ \
\
\ \
\ \
\ \
\ \
\
\ \
\
\ \
\
\ \ \
\ \ \
\ ', // 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);