Updated on March 29 2020 • Martin Shishkov

Integrating Google Maps into your web application can help your users in various ways, from just locating your business to more advanced purposes like filtering a set of hotels in a resort or helping your user to locate and choose his delivery address in a form.

In this article I'm going to cover the more sophisticated requirements that a client may have. Those are some of the following:

  1. How to customize marker pins with a template?
  2. How to place markers on a map based on ajax fetched data?
  3. How to create marker clusters in Google Maps?
  4. How to customize info windows with a template?
  5. How to deal with Google Maps' geometry API?

Demo

Map search demo gif

I've created a React component along with a demo at github which covers these topics, you can download the source code at https://github.com/MartinShishkov/maps-search-component

The demo represents a map with a circle that we can use in order to search for hotels in a given area by dragging the circle around. Data gets fetched with AJAX and the map gets updated with markers. We use custom templates for the markers and the info window.

1. How to customize marker pins with a template?

The built-in way of customizing marker pins is by specifying an icon image that will be displayed on the map. But I guess you probably already know that. Anyway here’s the link - https://developers.google.com/maps/documentation/javascript/custom-markers

Now, there's a great example for using custom markers at Trivago. It looks like this:

Trivago Map

You can’t have such markers by using icon images. So what should you do?

I learned this technique from this article: https://levelup.gitconnected.com/how-to-create-custom-html-markers-on-google-maps-9ff21be90e4b So props to Dan Ward (https://levelup.gitconnected.com/@warlyware)

There's something called OverlayView that can help us define our 'marker' object. OverlayView is basically an interface in the Google Maps API that you can use to create your own components that can render on the map canvas. The Google Maps team left the door open for us because they don't know what we might need. Just create your own implementation and you are good to go.

It also has methods to translate between screen and map coordinates. You can read more here: https://developers.google.com/maps/documentation/javascript/customoverlays

Here's my code for custom marker implementation with OverlayView:

const createHTMLMapMarker = 
    (map: Map, position: ICoordinate, 
        htmlContent: string, onClick: Function, 
        OverlayView = google.maps.OverlayView) => {

    class HTMLMarker extends OverlayView {
        private div: HTMLDivElement;
        private readonly HTMLContent: string;
        private readonly position: ICoordinate;

        constructor(settings: IHtmlMarkerSettings) {
            super();
            this.HTMLContent = settings.htmlContent;
            this.position = settings.position;
            this.attachEventListener("click", () => settings.onClick(this));
        }   

        .
        .
        .
        }

        return new HTMLMarker({
            map: map,
            position: position,
            htmlContent: htmlContent,
            onClick: onClick
        });
    };

Find the full version of the code here: https://github.com/MartinShishkov/maps-search-component/blob/master/src/MapSearch/Common/HTMLMarker.ts

The code is almost identical to Dan Ward's, I've just added some minor tweaks. This code enables you to set whatever HTML content you want for your marker and add event listeners to various events. Just what we needed!

In my code however I've declared a CustomMarker class, which acts as a wrapper around the HTMLMarker class and uses the `createHTMLMapMarker` function. This way I can add different implementations of the marker as much as I desire.

In the React side of things I have defined a simple react component that' used to render the marker contents for the Hotel Marker. React is really suitable for that kind of stuff because we can use the `renderToString` function. We don't have to concatenate magic strings to build HTML, none of that. It's really cool.

2. How to place markers on a map based on ajax fetched data?

In our MapSearch class we have a simple function that fetches the data.

private readonly fetchFor = (area: Circle) => {
    const position = area.getCenter();

    const geoModel = {
        radius: Math.floor(area.getRadius()),
        location: { lat: position.lat, lng: position.lng }
    };

    this.settings.fetchAction({
        params: geoModel,
        beforeStart: () => {
            this.loading.show();
            this.searchButton.disable();
            this.circle.disable();
        },
        onSuccess: (value) => {
            const data = value.data;

            const markers: CustomMarker<ISimpleData>[] = data.map((offer) =>
                new CustomMarker({
                    map: this.map,
                    data: offer,
                    renderer: this.settings.markerRenderer,
                    onClick: this.onMarkerClickHandler
                })
            );

            MarkerCluster.Create(this.map, markers);
            this.addMarkers(markers);
            this.searchButton.enable();
            this.circle.enable();
            this.loading.hide();
        }
    });
};

The ajax fetch action accepts the model – the center of the area and the radius of the circle. This way we can use them on the server to calculate which of our properties fall into the area and which do not. Just keep in mind that the server has to return a collection of items that have latitude and longitude properties – so we can put their markers on the map.

Actually the most interesting part is on the server side. How do you decide if a geo point falls into a given area? This is not as easy as in a cartesian coordinate system because we are dealing with a globe. We are using this function to calculate this:

const getDistanceBetweenTwoPoints = (lat1, lng1, lat2, lng2) => {
    const d1 = lat1 * (Math.PI  / 180.0);
    const num1 = lng1 * (Math.PI / 180.0);
    const d2 = lat2 * (Math.PI  / 180.0);
    const num2 = lng2 * (Math.PI / 180.0) - num1;
    const d3 = Math.pow(Math.sin( (d2 - d1) / 2.0 ), 2.0) 
        + Math.cos(d1) 
        * Math.cos(d2) 
        * Math.pow(Math.sin(num2 / 2.0), 2.0);

    return 6376500.0 * (2.0 * Math.atan2(Math.sqrt(d3), Math.sqrt(1.0 - d3)));
}

Using this function we can calculate the distance between the center of the circle and some arbitrary point. If this distance is greater than the radius of the circle this means the point is outside of the circle!

3. How to create marker clusters in Google Maps?

Now you have your custom markers, but you have so many of them that when you zoom out on the map it looks like crap. How cool would it be to have those in relative proximity be grouped and when you zoom-in to see every one of them without having them overlap and so on?

Since, there’s an official article on the topic I won’t be getting into details, you can find it here: https://developers.google.com/maps/documentation/javascript/marker-clustering

Keep in mind that you have to include the js script for marker clusters in your page besides the one for google maps itself. The script I'm using for marker clustering is slightly different from the official one. It has some options for setting the grid size (at what zoom level to group markers) and minimum cluster size.

My typescript implementation of the cluster: https://github.com/MartinShishkov/maps-search-component/blob/master/src/MapSearch/Common/MarkerCluster.ts

declare const MarkerClusterer: any;

interface IAnchor{
    getPosition: () => google.maps.LatLng,
    getMap: () => google.maps.Map,
    setMap: (map: google.maps.Map) => void,
}

export default class MarkerCluster{
    static readonly Create = (map: Map, anchors: IAnchor[]) => {
        const markerCluster = new MarkerClusterer(map.gmap, anchors, 
            {
                minimumClusterSize: 4,
                gridSize: 50,
                styles: [{
                    url: '/img/m3.png',
                    textColor: "white",
                    width: 51,
                    height: 54,
                    textSize: 15
                }]
            });
    };
}

Just call `MarkerCluster.Create` and pass a map reference and a collection of anchors. Anchors represent any object that implements a `getPosition()` method as well as `getMap()` and `setMap()` methods.

4. How to customize info windows with a template?

As I mentioned earlier: React is a really good choice when you would like to abstract away some component that expects to get HTML contents from outside, because we can use our JSX.Element type with props and call `renderToString(element)` in order to get the corresponding html string.

I used this technique again by just declaring a template element I would like to use for my Hotel info windows.

My TS implementation of the InfoWindow is nothing special. You can look it up here https://github.com/MartinShishkov/maps-search-component/blob/master/src/MapSearch/Common/InfoWindow.ts

What's cooler is the way I give my info windows a template that renders itself inside:

// props
infoWindowRenderer: (data: T) => JSX.Element;
    
// the actual renderer
infoWindowRenderer:
    (data: T) => renderToString(this.props.infoWindowRenderer(data));

If I had to concatenate strings to build this html I’d go mad. Just pass in the data and out you get the element.

5. How to deal with Google Maps' geometry API?

In my demo you can search for results in a given area by dragging the circle around and clicking the `Search` button on top. That's pretty neat but there is some tricky logic that goes into it.

First, while a search is in progress you shouldn't be able to move the circle around and start new searches.

Second, if you zoom close enough and the circle doesn't fit in the map canvas, we should lock the circle so that you don’t move or resize it accidentally. This provides a better user experience.

Third, if you haven't changed the position of the circle or it's radius since the last search – you shouldn't be able to start a new search, because, well… that means you are searching for the exact same area and just waste resources.

You can find the code here https://github.com/MartinShishkov/maps-search-component/blob/master/src/MapSearch/Common/Circle.ts

You can see that there are some events that you can attach callbacks to. They help us implement the requirements mentioned above.


this.attachEventListener("radius_changed", 
    () => handlers.onRadiusChanged(this));
this.attachEventListener("center_changed", 
    () => handlers.onCenterChanged(this));
this.attachEventListener("dragstart", 
    () => handlers.onDragStart(this));
this.attachEventListener("dragend", 
    () => handlers.onDragEnd(this));

How to check if the circle is within the boundaries of the map canvas?

First off, let's make it clear whose concern this is. It's clearly not circle's concern whether it is within the map because circle should not know of any map, just how and where to place and paint itself. Second, the map contains the circle but we really shouldn't tie our map implementation with our circle implementation.

So this is why my logic for this kind of stuff is in my MapSearchComponent, because it is specific to the component.

The logic whether or not the circle is bigger that the map canvas is that we calculate the circle diameter in pixels and then check if it’s greater than the height of the map.

How do you calculate circle diameter in pixels?

There’s a function in my map implementation that does this. It calculates how many meters there are per pixel and we can use that method to get the radius of the circle in pixels:

//Map.ts
readonly calcMetersPerPixel = (): number => {
    const bounds = this.mapInternal.getBounds();
    if (typeof (bounds) === "undefined")
    return null;

    const ne = bounds.getNorthEast();
    const sw = bounds.getSouthWest();
    const a = new google.maps.LatLng(ne.lat(), ne.lng());
    const b = new google.maps.LatLng(sw.lat(), ne.lng());

    // calculate google maps "height" in meters
    const mapHeightInMeters = 
        google.maps.geometry.spherical.computeDistanceBetween(a, b);
    return mapHeightInMeters / this.height;
};

//d.MapSearch.ts
private readonly convertMetersToPixels = (meters: number) =>
    Math.ceil(meters / this.map.calcMetersPerPixel());

private readonly convertPixelsToMeters = (pixels: number) =>
    Math.ceil(pixels * this.map.calcMetersPerPixel());