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 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: 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.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 FunctionsThe following methods are designed for overriding in order to customize behaviour | |
infoboxFunction 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. | 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;
}, |
Options to use for the Leaflet GeoJSON layer See also http://leaflet.cloudmade.com/examples/geojson.html e.g.
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 | 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;
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 providedActions 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);
} |
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 (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 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 © 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> \
<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);
|