/*jshint multistr:true */
this.recline = this.recline || {};
this.recline.View = this.recline.View || {};
(function($, my) {
"use strict";/*jshint multistr:true */
this.recline = this.recline || {};
this.recline.View = this.recline.View || {};
(function($, my) {
"use strict";This view allows to plot gereferenced records on a map. The location information can be provided in 2 ways:
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)
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;
},The following methods are designed for overriding in order to customize behaviour
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);
}
}
},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;
},Actions can be:
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 © 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();
},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')));
},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);