Reactive Google maps with Meteor

In this article we are going to create a list of places that updates while the user navigates a Google map. It will only show places from the database that are within the visible area of the Google map.

We'll use the dburles:google-maps package that's makes it easy for us to include a map in our template.

Find the full code example on GitHub. The demo can be found here.

First add the required package by running the following command in your terminal.

meteor add dburles:google-maps

Create the Places collection. Note that this line is not wrapped in a isClient or isServer function because we need the collection on both client as server side.

Places = new Mongo.Collection('places');

Create an array with names and coordinates of some places and populate the database with it. Make sure this code lives only on the server by wrapping it in a isServer function.

if (Meteor.isServer) {
    Meteor.startup(function() {
        var data = [
            {
                "name": "Nieuwmarkt, Amsterdam",
                "location": {
                    "type": "Point",
                    "coordinates": {
                        "lat": 52.372466,
                        "lng": 4.900722
                    }
                }
            },
            // ... code removed for clarity ...
            {
                "name": "Ransdorp, Amsterdam",
                "location": {
                    "type": "Point",
                    "coordinates": {
                        "lat": 52.392954,
                        "lng": 4.993593
                    }
                }
            },
        ];

        if(!Places.find().count()) {
            _.each(data, function(place) {
                Places.insert({
                    name: place.name,
                    location: {
                        type: 'Point',
                        coordinates: [
                            place.location.coordinates.lat,
                            place.location.coordinates.lng
                        ]
                    }
                })
            });
        }
    });
}

Create a publication that returns the places that are within a box. When using $geoWithin with the $box operator, $geoWithin returns the places based on grid coordinates. The box variable will therefore hold an array with latitudes and longitudes of our box. More on this later.

if (Meteor.isServer) {

    // ... code removed for clarity ...

    Meteor.publish('places', function(box) {
        var find = {
            location: {
                $geoWithin: {
                    $box: box
                }
            }
        };

        return Places.find(find);
    });
}

Remove the autopublish package because otherwise our publication will always return all places from the database. We only need those visible in our box.

meteor remove autopublish

Now on to the clientside code.

Create a template that will hold our map and the list of visible places.

<template name="map">
    <div class="map">
        {{> googleMap name="map" options=mapOptions}}
    </div>
    <ol class="places">
        {{#each places}}
            <li>{{name}}</li>
        {{/each}}
    </ol>
</template>

Load the Google maps assets and register an onCreated function to be called when an instance of our map template is created. Within this function we'll register another function which will check when our map is ready. map is the name of our map instance here.

if (Meteor.isClient) {
    Meteor.startup(function() {
        GoogleMaps.load();
    });

    Template.map.onCreated(function() {
        GoogleMaps.ready('map', function(map) {

        });
    });
}

For our template to work we'll need to create two helpers. mapOptions returns the options for the google map which centers on Amsterdam with a nice zoom level. places returns the data from the Places publication.

if (Meteor.isClient) {

    // ... code removed for clarity ...

    Template.map.helpers({
        mapOptions: function() {
            // Initialize the map
            if (GoogleMaps.loaded()) {
                return {
                    // Amsterdam city center coordinates
                    center: new google.maps.LatLng(52.370216, 4.895168),
                    zoom: 12
                };
            }
        },
        places: function() {
            return Places.find();
        }
    });
}

Next we want to know the coordinates of the visible area of our map (box). Because we'll be asking for these coordinates in multiple places we'll create a function for this. This function doesn't return anything but instead sets a Session variable which holds our coordinates. To use the $box operator in our mongo query, we must specify the bottom left and top right corners of the box in an array object.

if (Meteor.isClient) {

    // ... code removed for clarity ...

    function getBox() {
        var bounds = GoogleMaps.maps.map.instance.getBounds();
        var ne = bounds.getNorthEast();
        var sw = bounds.getSouthWest();
        Session.set('box', [[sw.lat(),sw.lng()], [ne.lat(),ne.lng()]]);
    }
}

For the places to actually show up on our map and list we need a subscription to our Places collection. We'll wrap this code in an autorun so that every time the Session variable with our coordinates changes we resubscribe to our collection using the updated box coordinates.

if (Meteor.isClient) {

    // ... code removed for clarity ...

    Template.map.onCreated(function() {
        var self = this;

        GoogleMaps.ready('map', function(map) {
            self.autorun(function() {
                getBox();
                Meteor.subscribe('places', Session.get('box'));
            });
        });
    });

    // ... code removed for clarity ...
}

Now we that we have a subscription to our Places collection we can add the markers to our map. We'll check when our subscription is ready, then query our collection and finish with looping through the results and adding the markers. Note the lookup variable which holds an array of all the markers already added. This way we avoid duplicate markers on the map.

if (Meteor.isClient) {
    var lookup = [];

    // ... code removed for clarity ...

    Template.map.onCreated(function() {
        var self = this;

        GoogleMaps.ready('map', function(map) {
            self.autorun(function() {
                getBox();
                var handle = Meteor.subscribe('places', Session.get('box'));
                if (handle.ready()) {
                    var places = Places.find().fetch();

                    _.each(places, function(place) {
                        var lat = place.location.coordinates[0];
                        var lng = place.location.coordinates[1];

                        if (!_.contains(lookup, lat+','+lng)) {
                            var marker = new google.maps.Marker({
                                position: new google.maps.LatLng(lat, lng),
                                map: GoogleMaps.maps.map.instance
                            });
                            lookup.push(lat+','+lng);
                        }
                    });
                }
            });
        });
    });

    // ... code removed for clarity ...
}

Now every time the box of our map changes (by zooming or dragging) we want the list of places next to our map to represent the visible places on our map.

Add the event listeners for the drag en zoom events and we're done.

if (Meteor.isClient) {

    // ... code removed for clarity ...

    Template.map.onCreated(function() {
        var self = this;

        GoogleMaps.ready('map', function(map) {

            // ... code removed for clarity ...

            google.maps.event.addListener(map.instance, 'dragend', function(e){
                 getBox();
            });

            google.maps.event.addListener(map.instance, 'zoom_changed', function(e){
                 getBox();
            });
        });
    });

    // ... code removed for clarity ...
}

Now play with zooming in and out and watch how our list represents the places visible on our map!

comments powered by Disqus