In this article, I will share my experience optimizing the rendering of a large number of unique map markers, focusing specifically on the Google Maps JavaScript API and Mapbox GL JS. By 'unique,' I refer to markers with distinct images.
Consider this scenario: we need to display points along planned routes on a map. To enhance clarity, each route is assigned a specific color, and each point is numbered from 1 to N. In my case, 20 colors were used, and the number of points per route could reach 160. This resulted in 3,200 unique color-number combinations, with the total number of markers potentially exceeding 10,000.
You might wonder why not use only 20 color-coded icons and overlay the numbers as separate elements. The issue with this approach is that when markers overlap—a frequent occurrence when users zoom out—the numbers appear detached, creating a visually jarring effect. Even with individual z-index settings for each image pair, the initial rendering would be acceptable, but the display would quickly degrade after zooming.
For this application, HTML markers are impractical due to their significant performance overhead with such a large quantity. Clustering is also unsuitable, as it would combine markers from different routes, undermining the goal of visually distinguishing planned routes. Furthermore, markers within a route are often connected by polylines representing the route's path.
Consequently, to achieve optimal visual representation and user experience, we must render thousands of markers, many of which are unique, presenting a significant performance challenge.
My initial attempt involved dynamically generating SVGs with the desired color and embedded number. Here's a simplified code snippet:
export const buildSitePin = (baseColor: string, borderColor: string, siteNumber: number): string => {
const innerText = `
<text
x="14"
y="18"
fill="#675e58"
text-anchor="middle"
font-size="${siteNumber < 100 ? '13' : '10'}">${siteNumber}</text>`;
const svg = `<svg>
<g>
<path ...real svg content here... fill="#${baseColor}" stroke="#${borderColor}"/>
... ${innerText} ...
</g>
</svg>`;
return `data:image/svg+xml;base64,${window.btoa(`${svg}`)}`;
};
While the generation process itself consumed minimal processor time, the browser's creation of images from data URLs <img src="data:image.....">
proved to be computationally expensive. Furthermore, the processing time increased proportionally with the number of unique images.
Additionally, rendering a map with a large number of 'heavy' SVG images resulted in a noticeably unresponsive user experience.
Therefore, the only viable solution was to pre-generate all icons and load them from the server. Loading thousands of individual images was clearly impractical. Instead, the images needed to be combined into an image sprite (a single image containing a collection of smaller images).
For this purpose, I utilized the @jutaz/spritezero library. First, the same function described earlier generated all the necessary SVG icons. Then, spritezero created a PNG sprite, as shown below:
It also produced an object containing data specifying the precise location of each icon within the sprite, which I then transformed into the following format:
{
"pins": [
{
"1": {
"x": 0,
"y": 0
},
"2": {
"x": 168,
"y": 160
}, ...
Here, the element's index within the pins array corresponded to the index of the required color (from our array of used colors). Using the color index, we could retrieve an object whose properties contained a number (the point's number within the route), and the value of the nested object {"x": 168, "y": 160 }
represented the coordinates of the top-left corner of the desired image within the sprite.
I intentionally omit the complete script code for generating icons, the sprite file, and the JSON file containing the sprite's metadata from this article. The purpose is not to provide a step-by-step guide for solving a specific problem, but rather to illustrate an approach applicable to similar tasks.
So, we have a sprite file and its metadata. In practice, I generated eight separate files to load only the necessary icons. The first file contained markers for all 20 colors with numbers from 1 to 20, the second from 21 to 40, and so on. Users with short routes, no more than 20 points each, would only load the first set of icons.
The final and most interesting part is how to integrate the sprite with our map implementations. We’ll start with Google Maps, where the process is relatively straightforward. We create a geospatial data layer and provide a function that returns google.maps.Data.StyleOptions
for each point. Within this, we need to return a google.maps.Icon
object, whose url field can contain either the URL of a single image or that of the sprite. For sprites, we also need to specify the origin: google.maps.Point
field (the position of the image within the sprite).
Here's an excerpt of the code where we retrieve the current point's data, determine the position of the required icon within the sprite using getPinPosInSprite
, and return google.maps.Data.StyleOptions
. As you can see, I also utilized the pixelRatio
field within the sprite's metadata, as I generated three versions of each sprite: one with standard pixel density (for low-resolution screens), one with double density (for Full HD), and one with quadruple density (for Retina and 4K displays). The sitePinsSpriteService
determined which sprite to use based on the current window.devicePixelRatio
value.
const layer = new google.maps.Data({ ... });
layer.setStyle((feature) => {
const colorIndex = Number(feature.getProperty('color'));
const siteNumber = Number(feature.getProperty('num'));
const sprite = this.sitePinsSpriteService.getSprite(siteNumber);
const posInSprite = this.getPinPosInSprite(colorIndex, siteNumber, sprite);
const coeff = sprite.pixelRatio;
const origin = new google.maps.Point(posInSprite.x / coeff, posInSprite.y / coeff);
return {
icon: {
url: sprite.gm.url,
size: new google.maps.Size(28, 40),
origin,
scaledSize: new google.maps.Size(
sprite.width / sprite.pixelRatio,
sprite.height / sprite.pixelRatio
)
}
};
});
After creating the layer in this way, you can add and remove points by calling the layer.add()
and layer.remove()
methods, which are described in detail in the Google Maps JavaScript API documentation.
Now, let's explore how to use sprites in Mapbox GL JS.
First, we need to add all the required images using the map.addImage(id, image, ...)
method, where image can be one of the following types:
addImage(
name: string,
image:
| HTMLImageElement
| ArrayBufferView
| { width: number; height: number; data: Uint8Array | Uint8ClampedArray }
| ImageData
| ImageBitmap,
options?: ...
)
The ImageData type particularly caught my attention. According to the documentation, 'The ImageData interface represents the underlying pixel data of an area of a element.'
You can retrieve ImageData from a Canvas object using the following method:
getImageData(sx: number, sy: number, sw: number, sh: number, settings?: ImageDataSettings): ImageData;
This means that given a canvas containing the sprite image, and knowing the required position within the sprite, you can obtain any desired fragment as ImageData, which can then be passed to Mapbox using the addImage
method.
One step remains: how to convert the sprite into a Canvas. Here's an example code snippet where, after loading the sprite, I create an OffscreenCanvas
and append the loaded image to it. OffscreenCanvas
is used because we don't need to render the Canvas itself on the page; we'll use it solely for extracting fragments from the sprite. Also, note the willReadFrequently
option, which optimizes memory usage for frequent read operations. Here's what MDN says about it:
Boolean that indicates whether or not a lot of read-back operations are planned. This will force the use of a software (instead of hardware accelerated) 2D canvas and can save memory when calling getImageData() frequently.
export const loadImageFile = (url: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const image = new Image();
image.src = url;
image.onload = () => resolve(image);
image.onerror = () => reject();
});
};
loadImageFile(spriteImageUrl).then(image => {
const { naturalWidth: width, naturalHeight: height } = image;
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(image, 0, 0);
})
Following this, we can add icons to Mapbox by retrieving them from our OffscreenCanvas as follows:
const imageData = ctx.getImageData(x, y, width, height);
where x
and y
represent the position of the desired icon within the sprite, and width
and height
are its dimensions. All of this information, as you recall, is available in the JSON files we generated alongside the sprite.
We then pass this imageData to the map using map.addImage(imageId, imageData)
.
After adding the images, we need to create a source that contains the data set in GeoJSON format. We also create a layer where we specify that the image for each feature in the GeoJSON should be the previously added icon, referenced by its imageId
(in my case, the imageId was a string like ${colorIndex}-${siteNumber}
).
const geo = {
type: 'FeatureCollection',
features: sites.map((site) => {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [site.longitude, site.latitude],
},
properties: {
num: site.siteNumber,
color: site.colorIndex,
imageId: `${site.colorIndex}-${site.siteNumber}`,
},
};
}),
};
map.addSource(sourceId, {
type: 'geojson',
data: geo
});
map.addLayer({
type: 'symbol',
source: sourceId,
layout: {
'icon-image': ['get', 'imageId']
},
});
As a result, several thousand markers with unique images are rendered in less than a second. This process requires pre-loading the necessary sprite files, which, in my case, occupied 118KB for FullHD or 270KB for 4K/Retina displays per 400 icons. However, since these files rarely change, they are effectively cached by the browser.
That concludes this article. Thank you for reading.
Top comments (0)