Bundling several Google Maps markers together

1 years ago

Bundling several Google Maps markers together

Important correction from March 11th, 2023

I have previously stated that the Advanced Markers library does not offer the possibility to rotate the markers.

This is incorrect as you can indeed apply CSS properties to an Advanced Marker's element.

You can find a (slightly altered) example of this, provided by Yong Su, on JsFiddle.

This article will remain as is while the Advanced Marker library is in beta, as an alternative for a production ready method.

(Original article continues)

Do you want to bundle several Google Maps markers at the same position in a nice way?

Hover and click on any of the bundled markers. Source code

I took the opportunity to write a very small package that does all of this for you, you can see it open sourced here: MarkerBundle. This article follows the creation of this package.

Get the default marker icon

The default icon is a PNG that can be found here.

You can find this url by checking your network tab while loading a map.

To be able to use it on our canvas, we have to create a new Image instance, in which we set the source attribute.

What's important here is that we use an anonymous cross origin, this is because otherwise our canvas will become tainted and can't be exported to a data url.

getMarkerImage() { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(); image.crossOrigin = "anonymous"; image.src = "https://maps.gstatic.com/mapfiles/api-3/images/spotlight-poi3.png"; }); };

Render our custom icon

To customize the downloaded icon, we have to first apply the rotation to our canvas context. Since we're rotating between -90 and 90 degrees, we have to account for the excess width.

To do this, we will set the canvas width to twice the height of the marker icon, and set the anchor to the center bottom of the canvas.

const canvas = document.createElement("canvas"); canvas.width = image.height * 2; canvas.height = image.height; const context = canvas.getContext("2d"); context.translate(canvas.width / 2, canvas.height); context.rotate(rotation * Math.PI / 180);

Now, we want to draw our marker and to account for our translation, we must set the left to be negative half the width and top negative the full height.

const left = -image.width / 2; const top = -image.height; context.drawImage(this.image, left, top, image.width, image.height);

This gives us the default red marker, now we have to fill the canvas with a custom color while using the "color" composite operation.

context.globalCompositeOperation = "color"; context.fillStyle = color; context.fillRect(left, top, canvas.width, canvas.height);

This fills the entire canvas with our custom color, so to extract the excess, we can draw the marker with a "destination-in" composite operation.

This removes everything beyond our marker image.

context.globalCompositeOperation = "destination-in"; context.drawImage(image, left, top, image.width, image.height);

When we've done this, we've got a rotated and colored marker, but if we want to stack several custom markers on top of each other, we have to use one single image.

This is because since we'll be applying our custom icon as a PNG, there will be no transparency bypassing, which would mean our cursor would detect the marker on fully transparent spots.

This would effectively mess up the "z-index" detection of our markers.

By using one single marker, we can handle the mouse detection ourselves, by using the image data from our canvas.

return { image: canvas, imageData: context.getImageData(0, 0, canvas.width, canvas.height) };

Rendering the bundle marker

Now that we can render individual markers, we want to render all of our markers on a single canvas.

When we do this, we want to render each marker individually first, extract the image data and cache the image and the image data in an array. This is to be used for the mouse detection later on.

So when we add a marker, we want to render all of our markers right there and then, so that we don't have to do this expensive execution unnecesarily on each render.

addMarker(marker) { marker.setMap(null); this.markers.push(marker); this.images = []; for(let index = 0; index < this.markers.length; index++) { const color = this.colors[index]; const rotation = -45 + (index * (90 / (this.markers.length - 1))); this.images.push(this.renderMarker(this.image, color, rotation)); } this.render(); };

By setting the "minimum" angle to -45 degrees, we have 90 degrees to throw our markers around in, so if we divide 90 by our marker count, we get the angle per marker.

It's important that we take the added marker out of the map instance, as to not actually display it.

After adding a marker, we have to re-render our bundled marker icon, so we call render to do this.

render() { const canvas = document.createElement("canvas"); canvas.width = this.image.height * 2; canvas.height = this.image.height; const context = canvas.getContext("2d"); context.globalCompositeOperation = "destination-over"; if(this.hovered !== -1) context.drawImage(this.images[this.hovered].canvas, 0, 0); this.images.forEach((image, index) => { if(this.hovered === index) return; context.drawImage(image.canvas, 0, 0); }); this.marker.setIcon({ url: canvas.toDataURL(), anchor: new google.maps.Point(canvas.width / 2, canvas.height) }); };

Here we effectively create a blank canvas like before, and then assign a destination-over composite operation. This will cause the drawings we make on the canvas to be added underneath the existing canvas.

This is important because otherwise our markers will be in the reverse order, from right to left.

And to add the effect of hovering of a marker, we want to draw our hovered marker before the other markers, if we do have a hovered marker.

Capturing mouse events

To detect if we do have a hovered marker, we must now create a mouse detection. Using the image data, we can use the pixel alpha to calculate if a pixel if transparent or not.

isMouseOverImage(imageData, width, offsetX, offsetY) { const alphaIndex = ((offsetY * width + offsetX) * 4) + 3; const alpha = imageData.data[alphaIndex]; if(alpha < 254) return false; return true; };

I have opted to accept alpha 255 and 254 as fully transparent because I've noticed that some browsers tend to round downwards and not upwards sometimes, causing a 254 alpha for what's actually a fully transparent pixel.

Since the Maps JavaScript API does not provide a mousemove event for markers, we must first listen to mouseover events to the marker, then assign an mousemove event listener to our map, then unlisten to it on mouseout from our marker:

this.marker.addListener("mouseover", (event) => { this.mousemoveListener = this.map.addListener("mousemove", this.mousemove.bind(this)); }); this.marker.addListener("mouseout", (event) => { google.maps.event.removeListener(this.mousemoveListener); if(this.hovered !== -1) { this.hovered = -1; this.render(); } });

Now in our mousemove event, we first want to check if we do have an hovered marker, and if it's still hovered, then trigger the mousemove event on the marker and cancel the function.

If we don't have a hovered marker, however, then we want to iterate through our markers from the end to start, this is in accordance to our destination-over composite operation.

mousemove(event) { if(this.hovered !== -1) { if(this.isMouseOverImage(this.images[this.hovered].imageData, this.images[this.hovered].canvas.width, event.domEvent.offsetX, event.domEvent.offsetY)) { google.maps.event.trigger(this.markers[this.hovered], "mousemove", event); return; } google.maps.event.trigger(this.markers[this.hovered], "mouseout", event); this.hovered = -1; this.render(); } for(let index = this.images.length - 1; index != -1; index--) { if(!this.isMouseOverImage(this.images[index].imageData, this.images[index].canvas.width, event.domEvent.offsetX, event.domEvent.offsetY)) continue; if(index != this.hovered) { this.hovered = index; google.maps.event.trigger(this.markers[this.hovered], "mouseover", event); this.render(); } google.maps.event.trigger(this.markers[this.hovered], "mousemove", event); return; } };