654 lines
20 KiB
JavaScript
654 lines
20 KiB
JavaScript
/*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:
|
|
//
|
|
// <pre>
|
|
// {
|
|
// // 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
|
|
// }
|
|
// </pre>
|
|
//
|
|
// 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 = 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 += '<div><strong>' + key + '</strong>: '+ record.attributes[key] + '</div>';
|
|
}
|
|
}
|
|
return html;
|
|
},
|
|
|
|
// Options to use for the [Leaflet GeoJSON layer](http://leaflet.cloudmade.com/reference.html#geojson)
|
|
// 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);
|
|
}
|
|
|
|
// 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 <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;
|
|
_.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);
|
|
|