Loccasions: Bubbly Map Events

Glenn Goodrich
Share

In what I hope will be the last of the coding for this series (even if it could use TONS more) I am focusing on reacting to map events. In this case, the map events in question are the user interacting with the map to add Occasions. Specifically, I want the user to be able to add an Occasion by clicking on the map. I’ll use a cool map bubble to present the attributes form for the Occasion, and we’ll post the new Occasion back to the server once the user submits the form. Easy, right?

Responding to Map Clicks

The first thing we need to do is respond to the user clicking on the map. All of these new-fangled javacript map frameworks make this a piece of cake.
Going WAAAY back to one of the first articles, I have abstracted the Leaflet map framework into a provider object. In order to allow the code to set a handler for the map click event, I am going to add a addClickHandler event to that provider object. Since all it does is delegate the call to Leaflet, I am not going to write a test for it.

// in app/assets/lib/leafletMapProvider.js.coffee
...
addClickHandler: (map, callback) ->
  map.on('click', callback)

This method will be called in the render method of our map view:

// the test
// in spec/javascripts/views/mapView_spec.js
describe("render", function() {
    beforeEach(function() {
      this.mock = this.mapProviderSpy
    });
    it("should add a click handler to the map", function() {
      this.view.render();
      expect(this.mapProviderSpy.addClickHandler).toHaveBeenCalled();
    });
  });
// in app/assets/javascripts/views/map_view.js.coffee
...
render: ->
    @map = new @mapFactory.Map(@el.id)
    @mapFactory.addClickHandler(@map,@newOccasion.bind(@))
    @addBaseLayer()
    @setInitialView()

Our render method is getting pretty busy, but I can live with it. You can see that I am passing in a newOccasion method as the handler. This is the function that will receive our map event and, by my estimation, it needs to do two things:

1) Put a marker on the map where the click occurred.
2) Show the new Occasion form.

Here are our tests:

// in spec/javascripts/views/map_view.js.coffee
describe("When a new Occasion is requested", function() {
    beforeEach(function() {
      var e = {
        latlng: {lat: 100.00, lng: 100.00}
      };
      this.view.newOccasion(e);
    });

    it("should have a form", function() {
      expect($("#new_occasion_form").length).toEqual(1);
    });

    it("should add a marker to the map", function () {
      expect(this.mapProviderSpy.addNewOccasionMarker).toHaveBeenCalled();
    });
});

And the code:

// in app/assets/javascripts/views/mapView.js.coffee
newOccasion: (e) ->
    @mapFactory.addNewOccasionMarker(@map, e)

I neglected to show you the implementation of the addNewOccasionMarker on the Leaflet map provider, so here it is:

// in app/assets/lib/leafletMapProvider.js.coffee
...
addNewOccasionMarker: (map, e) ->
    ll = new L.LatLng(e.latlng.lat,e.latlng.lng)
    marker = new L.Marker(ll)
    map.addLayer(marker)

At this point, if you go to a specific event page, you should be able to click on the map and markers will show up where you click. Kinda fun, isn’t it?

The second part of adding a new Occasion is to show the form. We already have a form to create Occasions on the page, but we don’t want it there. We want the form in our fancy map bubble. To make our map bubble dreams come true, I did the following:

Change the Event Show View

The event#show HAML template currently has:

%div.clear
%div#edit_occasion{ :style => "display:none"}
  = form_for [@event, current_user.events.find(@event.id).occasions.build()] do |f|
    %div.coordinate_input
      = f.label :latitude
      = f.text_field :latitude
    %div.coordinate_input
      = f.label :longitude
      = f.text_field :longitude
    %div.date_field
      = f.label :occurred_at
      = f.text_field :occurred_at
    %div.note_field
      = f.label :note
      = f.text_area :note
    = f.submit "Add"

I changed the form from having an id of “edit_occasion” to a class of “edit_occasion”. In other words, change the “#” to a “.”.

Remove the CreateOccasionView Call from EventRouter

I was new-ing up an the CreateOccasionView inside our EventRouter. I don’t want to do that anymore, so take that call out.

App.EventRouter = Backbone.Router.extend
  routes:
    ""      : "index"
  index: ->
    @occasionListView = new App.OccasionListView
      collection: window.occasionCollection or= new App.OccasionsCollection() 
    @occasionListView.render()
    @createOccasionView = new App.CreateOccasionView() # REMOVE THIS LINE
    if $('#map').length > 0
      @mapView = new App.MapView(App.MapProviders.Leaflet)
      @mapView.render()

Create a CreateOccasionView When the Map is Clicked

Since I want the view to show on map click, we can put a call to show that view in the same place we create the marker.

// in app/assets/javascripts/view/mapView.js
newOccasion: (e) ->
    view = new App.CreateOccasionView()
    view.render()
    @mapFactory.addNewOccasionMarker(@map, e,view.el )

I pass the event and the view render into the map provider, because I don’t want my mapView to know anything about the event contents. The addNewOcccasionMarker function will deal with getting the coordinates and populated the form inputs. This is, admittedly, a bit messy, but we’re on a fake deadline here.

Because we are showing the form everytime the user clicks, I am going to clone the original form and use it as a template for each CreateOccasionView.

//in app/assets/javascripts/views/createOccasionsView.js.coffee

<p>App.CreateOccasionView = Backbone.View.extend(
  initialize: ->
    @template = $(".edit_occasion").clone().children("form").end()
    $(@el).append(@template)
    $(@el).find('div').show()
    @form = $(@el).find("form")
...

Now, I can populate the form with the latitude and longitude from the map event.

//in app/assets/javascripts/lib/leafletMapProvider.js.coffee
...
addNewOccasionMarker: (map, e, content) ->
    ll = new L.LatLng(e.latlng.lat,e.latlng.lng)
    marker = new L.Marker(ll)
    marker.bindPopup(content)
    map.addLayer(marker)
    marker.openPopup()
    $("#occasion_latitude").val(e.latlng.lat)
    $("#occasion_longitude").val(e.latlng.lng)
    $("#occasion_occurred_at").val(new Date())

This (overly) busy function is creating our marker and putting the latitude, longitude, and occurred date into the form. I should probably hide those form inputs from the user, eh?

%div.edit_occasion{ :style => "display:none"}
  = form_for [@event, current_user.events.find(@event.id).occasions.build()] do |f|
    %div.coordinate</em>input
      = f.hidden_field :latitude
    %div.coordinate_input
      = f.hidden_field :longitude
    %div.date_field
      = f.hidden_field :occurred_at
    %div.note_field
      = f.label :note
      = f.text_area :note
    = f.submit "Add"

I went ahead and removed the labels, too. This is what our cool new form looks like:

OOOO...Pretty map form...

The even cooler thing is, it works. Type in a note and hit ‘Add’ and blammo! you have a new Occasion show up on the map and in the list next to the map. Backbone is just cool.

More Housekeeping

I am sure you have spent the last 45 minutes creating Occasions like a maniac. I can’t blame you, really, it’s pretty danged exciting. I bet you said, at least once, “I wish the form would go away when I create the Occasion.” Well, strap in, sporto, today is your lucky day.

The question of making the form disappear lead me to the realization that part of my design was, um, how do I put this mildy….., dog vomit. First of all, the MapProvider is an OK idea, but it should have returned a Map object with all the methods I need. The current approach of calling methods on the MapProvider and passing in the map is, as I said, vomitus caninus.

If I ever refactor this app, I will likely start there. As it stands, I need to get this working using the current design so I can finish this article.

Back to making the form disappear. It’s easy enough to do, and it has given me the opportunity to show a cool Backbone feature: custom events. As you might’ve guessed, custom events allow you to trigger your very own named events and then bind to them as needed. I am going to use this to indicate to the MapView that an Occasion has been created.

The CreateOccasionView is in charge of creating the new Occasion (duh) so I am going to raise a custom event from that view called “map:occasionAdded.”

// in app/assets/javascripts/views/createOccasionView.js.coffee
...
createOccasion: ()->
    occasion = new App.Occasion(UTIL.parseFormAttributes(@form, "occasion").occasion)
    has_id = @form.attr("action").match(//occasions/(w*)/)
    if has_id
      occasion.id = has_id[1]
      occasion.save()
    else
      occasionCollection.create(occasion)
      $(this.el).trigger('map:occasionAdded', occasion) //THIS IS THE NEW LINE

All it takes is one line and we’re cooking with custom events. I’ll bind to this event in the MapView and tell the MapProvider (ugh) to hide the popup.

//in app/assets/javascripts/mapView.js.coffee

App.MapView =  Backbone.View.extend
  el: "div#map"
  events:
    "map:occasionAdded" : "handleSubmit"
...
  handleSubmit: ->
    @mapFactory.hidePopup(@map)

And the method called in the map provider:

// in app/assets/javascripts/lib/leafletMapProvider.js.coffee
...
 hidePopup: (map)->
    map.closePopup()

So, that’s great, the popup is gone, baby, gone. However, we have a remaining issue. If you click on the marker you just created, it shows the form. I don’t want that, I want it to show the note like the other markers, like so:

Things Happen

This issue led me to another faux paus where I am not binding the map markers to the Occasion collection. That’s just silly, because that kind of binding is the WHOLE REASON to use something like Backbone. It was easy enough to fix, because Backbone is the bees knees (which, I think, is a good thing.)

In a nutshell, I bound the ‘add’ and ‘all’ events of the occasionCollection to methods on the MapView. These methods then add the new Occasion or regenerate all the markers, as needed. Here they are:

App.MapView =  Backbone.View.extend
...
  initialize: (mapProvider) ->
    @mapFactory = mapProvider
    @collection = window.occasionCollection
    @collection.bind("add", @addOccasion, this)
    @collection.bind("all", @drawOccasions, this)
  drawOccasions: () ->
    self = this
    @mapFactory.removeAllMarkers(@map)
    window.occasionCollection.each((occ)->
      self.mapFactory.addOccasion(self.map,occ)
    )
  addOccasion: (occ) ->
    @mapFactory.addOccasion(@map, occ)
...

There, now when an Occasion is born (they’re so cute when they’re new…) the map will add a marker. The ‘all’ method covers an Occasion being deleted.

The more astute amoung you realize that, now, adding an Occasion leaves two markers in the new spot. So, along with hiding the popup after creating an occasion, we need to delete the map marker that we used to show the form. Again, not too bad.

App.MapProviders.Leaflet  =
...
  addOccasion: (map,occ) ->
    if not @layerGroup?
      @layerGroup = new L.LayerGroup()
      map.addLayer(@layerGroup)
    ll = new L.LatLng(
      parseFloat(occ.get("latitude")),
      parseFloat(occ.get("longitude"))
    )
    marker = new L.Marker(ll)
    marker.bindPopup(occ.get("note"))
    @layerGroup.addLayer(marker)
  addNewOccasionMarker: (map, e, content) ->
    ll = new L.LatLng(e.latlng.lat,e.latlng.lng)
    @marker = new L.Marker(ll)
    @marker.bindPopup(content)
    map.addLayer(@marker)
    @marker.openPopup()
    $("#occasion_latitude").val(e.latlng.lat)
    $("#occasion_longitude").val(e.latlng.lng)
    $("#occasion_occurred_at").val(new Date())
  hidePopup: (map)->
    map.closePopup()
    map.removeLayer(@marker)
  removeAllMarkers: (map) ->
    if @layerGroup?
      @layerGroup.clearLayers()

There is a fair amount going on here:

1) I have added a layerGroup property to the provider. A layer group is a Leaflet concept that allows you to group a bunch of layers (or, in this case, markers) together. The LayerGroup object in the Leaflet API has a clearLayers() function, and that is what I need when I want to clear out all the markers so I can regenerate them.
2) In addNewOccasionMarker(), I add another property called marker and store our “temporary” marker for the form. Now, I can get it back when I want to clear it out.
3) In hidePopup(), I remove the temporary marker after I hide the popup.
4) removeAllMarkers() clears out the layer group, as I previously mentioned.

All in all, it’s not terrible, but those last additions really show the design flow in my provider approach. A factory would have been better, and it will be the first refactor.

Basic Occasion Functionality

Loccasions now has all the basic functionality that I envisioned those many months ago. It’s not ground breaking, but it does show some nice technical concepts and I certainly learned a ton. The last post (at least, for awhile) in this series is next, and I plan on it being a retrospective. I’ll look at where Loccasions could go and how I could have done things better.

I am sure I’ll have plenty of content for that one.

CSS Master, 3rd Edition