In the previous posts, we created a simple Sinatra API for CRUD operations on points of interest (POIs) then used Yeoman and OpenLayers to create a web application to render these POIs on a map.
In this third and final post, we’ll add the functionality for creating and editing the POIs.
Note: This post sat around half finished for some months during which time new major versions of both Yeoman and OpenLayers were released. Significant parts of it are now dated, but I recently decided to put a bookend on the series for completeness - mostly because my current job is all server side and web development is a novelty for me at the moment.
The first thing we’ll do is add a some hover-over text to our map icons so we know what any given POI actually is. For simplicity, we’ll just show the name of the POI.
To indicate that a given POI has our focus, we need a new style for selection. We’ll just change it from blue to green. So our style from the previous part becomes this.
var styleMap = new ol.StyleMap({
default: {
pointRadius: 7,
fillColor: 'blue',
strokeWidth: 1
},
select: {
fillColor: 'green'
}
});
Then we need to create an OpenLayers control to handle the hover events. We’ll create more than one control, so let’s add a setupControls method that gets called in the init method of our mapping module. In that method goes this code. We’re indicating that the control is activated by hovering over the feature and that we want to apply the select style we defined.
var highlightCtrl = new ol.Control.SelectFeature(poiLayer, {
hover: true,
highlightOnly: true,
renderIntent: 'select',
eventListeners: {
featurehighlighted: onPoiHighlighted,
featureunhighlighted: onPoiUnhighlighted
}
});
map.addControl(highlightCtrl);
highlightCtrl.activate();
We’ve also hooked up two event listeners in our control definition, so we’d better add those handlers to our mapping module too. They look like this.
var onPoiHighlighted = function(e) {
var feature = e.feature;
name = poiCollection.get(feature.attributes.modelId).get('name');
anchor = feature.geometry.getBounds().getCenterLonLat();
var popup = new ol.Popup.Anchored('poiPopup', anchor, new ol.Size(100,20), name, null, false, null);
feature.popup = popup;
popup.feature = feature;
map.addPopup(popup, true);
};
var onPoiUnhighlighted = function(e) {
removeOlPopup(e.feature)
};
var removeOlPopup = function(feature) {
if (!feature.popup) return;
map.removePopup(feature.popup);
feature.popup.destroy();
feature.popup = null;
};
The popup that this affects is a simple box without styling. OpenLayers has different types of popups as well as properties to control appearance. If that isn’t enough, you can simply apply CSS to the element it creates. In the interest of brevity I’ll leave this as an exercise for the reader.
This example only requires one view. In its first form, we’ll just get it to show us the summary of a POI that we select from the map.
$ yeoman init backbone:view poi
This creates a Backbone view and a template. Because we’re keeping it simple, we’ll just define the template directly in index.html. I deleted the one that Yeoman generated.
Let’s look at the template first. What we want to do is show a Bootstrap modal when we create or click POIs. For this example we’ll use the templating provided by Underscore. Again, for expediency I included the delete button in this modal, though in the wild this functionality might be separate - perhaps invoked via another OpenLayers control. This is what we add to index.html.
<script type="text/template" id="poi-template">
<div id="poi-modal" class="modal hide fade">
<div class="modal-header">
<h3>Point of Interest</h3>
</div>
<div class="modal-body">
<label>Name</label>
<input type="text" id="poi-name" value="<%= name %>"/>
<label>Description</label>
<input type="text" id="poi-desc" value="<%= desc %>"/>
<label>Position</label>
<label><%= pos %></input>
</div>
<div class="modal-footer">
<button id="btn-close" class="btn">Close</button>
<button id="btn-delete" class="btn btn-danger">Delete</button>
<button id="btn-save" class="btn btn-primary">Save</button>
</div>
</div>
</script>
Backbone views need a render method. Ours gets the template, compiles it using a PoiModel instance, appends the view to the page body, then shows the modal. When we’re adding a POI we don’t want to show the delete button. We can detect this situation based on whether the model has the MongoDB generated ID. This is how it looks.
olBlogPost.Views.PoiView = Backbone.View.extend({
render: function() {
var html = _.template( $('#poi-template').html(), this.model.toJSON() );
this.$el.html(html).appendTo('body');
if (!this.model.id) $('#btn-delete').hide();
$('#poi-modal').modal();
return this;
}
});
Now that we have our POI view, we need another OpenLayers control to handle feature selection. All we’re doing is specifiying that selected POIs use the select style we defined above, and designating the handler for this event.
var selectCtrl = new ol.Control.SelectFeature(poiLayer, {
renderIntent: 'select',
onSelect: onPoiSelected
});
map.addControl(selectCtrl);
selectCtrl.activate();
The handler simply closes the hover popup if it is active and renders the Backbone view with the model for the selected POI. It looks like this.
var onPoiSelected = function(e) {
removeOlPopup(e);
var poiView = new root.olBlogPost.Views.PoiView({ model: poiCollection.get(e.data.modelId) });
poiView.render();
};
Here’s is what our rendered view looks like.
So now we have a view that will be rendered with the data for any POI that we click on. Unfortunately, as it is, none of the buttons do anything, so we can’t even close the modal. It’s time to hookup some events for our view.
We can easily map events on our controls to methods by defining the mappings in the view’s events object. We need events for closing the modal, for deleting the POI and for saving the POI, which is handled automatically for us by Backbone as an upsert. We also need to manage our map based on these actions, so we fire externally visible events using the trigger method. Here are the mappings and the methods.
events: {
'click button[id=btn-close]': 'closePoi',
'click button[id=btn-delete]': 'deletePoi',
'click button[id=btn-save]': 'savePoi'
},
closePoi: function(e) {
$('#poi-modal').modal('hide');
this.trigger('closed');
this.remove();
},
deletePoi: function(e) {
this.model.destroy();
this.trigger('deleted');
this.closePoi();
},
savePoi: function(e) {
this.model.set({
name: $('#poi-name').val(),
desc: $('#poi-desc').val()
});
var self = this;
this.model.save(null, {
success: function(model, response) {
modelId = model.get('id');
if (modelId) self.trigger('added', modelId);
}
});
this.closePoi();
}
When we click the close button we want to close the modal and remove the view from the DOM. As we’ll see later, we also want to do some conditional handling depending on the purpose for which the view was opened; so we’ll also trigger an event. When opening the view for an existing POI, the handler for this event simply ensures no features are selected.
From the view render method, we can see that the delete button is not visible when opened in add mode. When this button is clicked we want to:
Here is our selection method with the view events handled.
var onPoiSelected = function(e) {
removeOlPopup(e);
var poiView = new root.olBlogPost.Views.PoiView({ model: poiCollection.get(e.attributes.modelId) });
poiView.on('closed', function() { selectCtrl.unselectAll(); });
poiView.on('deleted', function() { poiLayer.removeFeatures([e]); });
poiView.render();
};
Adding a feature to a vector layer requires a DrawFeature control. When activated, a click on the map will add a new feature at that location. If we’ve registered a handler with the event, it will fire. This is what we add to our setupControls method.
addCtrl = new ol.Control.DrawFeature(poiLayer, ol.Handler.Point);
addCtrl.events.register('featureadded', addCtrl, onPoiAdded);
The handler looks like this.
var onPoiAdded = function(e) {
var point = e.feature.geometry.getVertices()[0].transform(toProj, fromProj);
var poi = new olBlogPost.Models.PoiModel({
name: 'New POI',
pos: [point.x, point.y]
});
poiView = new root.olBlogPost.Views.PoiView({ model: poi });
poiView.on('added', function(modelId) {
poi.id = modelId;
poiCollection.push(poi);
poiLayer.removeFeatures([e.feature]);
poiLayer.addFeatures([getFeatureFromPoi(poi)]);
})
poiView.on('closed', function() { poiLayer.removeFeatures([e.feature]); });
poiView.render();
};
I had a minor issue here where no matter what I tried, the feature drawn by the add control ended up on the unrenderedFeatures list for the layer. In the end I actually used less code to just remove the drawn feature and add a fresh one generated from the POI in the same fashion as when initialising the layer.
Naturally we need a mechanism for switching between the modes of (1) selecting POIs on the map to view or delete, and (2) adding new ones. This is the public method I added to the mapping module. It just toggles the OpenLayers controls we created above.
var togglePoiMode = function(adding) {
if (adding) {
selectCtrl.deactivate();
addCtrl.activate();
} else {
selectCtrl.activate();
addCtrl.deactivate();
}
};
Going for brevity again, I used plain old Bootstrap navigation list items and jQuery click bindings to switch between the modes.
That’s it! A simple-as-can-be UI for adding/updating/deleting points of interest on a map with OpenLayers and Backbone, and persisting the data to MongoDB via a Sinatra API. I’ve dumped the code as it is into a GitHub repository here. One enhancement that comes to mind is the ability to relocate POIs by dragging them around the map. Someone might find a use for it as a template for a more sophisticated application, or maybe just make the code better. I’d certainly appreciate the feedback.
Happy hacking.