๐พ Branded Static Maps
Remember the last time you took a trip with Uber/Lyft/Bolt? Just as the trip ended, you probably got an email containing the receipt and trip summary. This trip summary then contained the route โscreenshotโ:
The map โscreenshotโ is what we call a static map.
In contrast to dynamic maps which you can zoom, pan, and move , static maps are image snapshots (GIF, PNG, or JPEG) of a given viewport.
As you remember from the chapter on map tiles , dynamic raster maps are themselves composed of styled image tiles. The Static API lets you request the very same tiles โ on demand.
In this chapter, you'll learn how to leverage the Google Maps Static API to:
- generate map images consistent with your brand,
- correctly place custom markers,
- and efficiently draw semi-transparent polylines.
For my ride-hailing app I'm working on an email that contains an image snapshot of the route. I'd like to visually differentiate between the origin and the destination. Plus, I need to make sure the map pins are correctly positioned:
First off, turn on billing and enable the Maps Static API for your API key.
After that, you'll be requesting the
https://maps.googleapis.com/maps/api/staticmap?key=AIza... endpoint with various query parameters.
The most basic query parameter is the size :
Basic static map with the default zoom of 0 and ?size=312x358
Just like with dynamic maps , static maps can be styled using Map IDs by passing the map_id attribute:
Basic static map with a map_id and ?size=312x358 &map_id=cf19af61093c176a
Static maps can be centered either using geocodeable address strings like โVienna, Austriaโโฆ
Static map centered on Vienna, Austria and ?size=312x358 &map_id=cf19af61093c176a ¢er=Vienna,Austria
โฆor by passing a latitude/longitude pair and an optional zoom level.
To refresh your understanding of coordinates and learn how to obtain them, go back a few chapters.
Static map centered on Vienna, Austria and ?size=312x358 &map_id=cf19af61093c176a ¢er=48.2082,16.3738 &zoom=13
To place a custom marker on the static map, you'll need a marker URL (e.g. this one from Wikimedia ) and its position.
With these two pieces of information, you'll construct a query parameter of the form:
&markers=|icon:...|lat,lng that is:
&markers=|icon:https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Google_Maps_pin.svg/20px-Google_Maps_pin.svg.png|48.2082,16.3738 resulting in โ
Static map with a custom marker icon
Unlike with dynamic maps' custom marker labels , static map marker labels cannot be SVGs โ neither standard .svgs nor encoded data URLs like data:image/svg+xml.
Only PNGs, JPEGs, or GIFs are permitted โ though PNGs are recommended.
One more restriction applies: icons may only be up to 4096 pixels in size (64x64px for square images). Any invalid marker icon will fall back to the default, red Google pin.
Oftentimes, invalid parameters (marker-related or not) will show a yellow โMap errorโ warning in the top right corner. To inspect the error message closer, look for the x-staticmap-api-warning response header :
By default, the Static Maps API will position the markers bottom center. If you need to align the markers differently (i.e. to center them on the x/y axes), you can use one of the predefined alignments: top, bottom, left, right, center, topleft, topright, bottomleft, or bottomright.
The anchor URL query parameter also accepts x,y values. See this anchoring deep dive section for more info.
To verify that you positioned the marker correctly, place an extra marker for testing purposes:
Note that order matters โ the &markers= attributes are rendered in the reverse order they appear in the request URL. So, to place a marker โon topโ of another marker, pass it before the other markers parameters.
In the chapter on lines, you learned how to work with ordered sets of coordinates to construct polylines.
The Maps Static API also lets you draw polylines. You only need a list of lat/long coordinates separated by the pipe symbol (|):
&path=|48.26491,16.398|48.26408000000001,16.397640000000003|48.263130000000004,16.40098|48.265150000000006,16.40229|48.266070000000006,16.397920000000003|48.26594,16.396510000000003|48.26561,16.39609|48.261570000000006,16.39431|48.2603,16.39292|48.25309000000001,16.375210000000003|48.25213,16.37348|48.250260000000004,16.37141|48.247890000000005,16.37002|48.241640000000004,16.36448|48.235780000000005,16.36089|48.23377000000001,16.36046|48.232110000000006,16.3609|48.22854,16.36286|48.22576,16.366200000000003|48.22482,16.36694|48.22108,16.367420000000003|48.219260000000006,16.368080000000003|48.21585,16.36069|48.21437,16.359260000000003|48.21425000000001,16.35734|48.21186,16.35661|48.21177,16.355600000000003|48.20864,16.354940000000003|48.206770000000006,16.355700000000002|48.205670000000005,16.357100000000003|48.205290000000005,16.352230000000002|48.20543000000001,16.34915|48.204240000000006,16.348940000000002|48.199870000000004,16.34928|48.19973,16.34814 which would produce a blue line with the default width of 5px:
Default, blue, 5px polyline &path=48.26491,16.398|...
To adjust its width, pass a weight attribute:
A 3px polyline &path=weight:3|48.26491,16.398|...
To change the line's color, use the color attribute.
Since polyines are 50% transparent by default , append the opacity as an ff to the 32-bit hexadecimal value to make the polyline โfullyโ black:
Pitch-black 4px polyline &path=color:0x000000ff|weight:4|48.26491,...
Sorry, 32-bit what?
You're probably used to traditional, 24-bit HEX color strings, e.g. #FFFFFF for white and #000000 for black. Such strings, when deconstructed, specify the RGB (red/green/blue) channels:
# FF FF FF
^^ red
^^ green
^^ blue
In hexadecimal notation , FF represents 255, i.e. the highest available channel value. Then, 8 bits per color amount to 24 bits in total.
In Google Maps Static API, you'll prepend the hex color strings with an 0x instead of a #.
Time for a slight detour. As I'm writing this chapter and inspect Notion's background color, #191919, the RGB channels are equally set to the hexadecimal 19, which, in RGB-speak is 25 out of 255:
The last channel (100) is the opacity. We'll come back to it momentarily.
Notion's native app is built with Chromium โ so you can toggle the Chrome DevTools directly in the app (run ctrl + alt + i / โ + โฅ + i). You can then use DevTools' color picker to inspect and modify colors:
With RBG, opacity is usually expressed as a decimal value between 0 and 1. This transparency (โalphaโ) channel is appended to the color definition. For a semi-transparent black, you'd use rgb(0, 0, 0, 0.5).
To convert these 50% to a hex value, remember that the maximum channel value is 255. 50% of 255 is roughly 127, and 127 converted to hexadecimal is 7F. So, to color your polyline with semi-transparent black, you'd pass 0x0000007F โ a 32-bit hexadecimal value.
Google Maps Static API puts a hard limit on the request URL length โ namely 8192 characters.
But your polylines might be longer than that.
Even if the coordinates are rounded to 5 decimal places and assuming all coordinates are negative and double digit (e.g. -12.12345), that's 9 characters per coordinate, 18 per pair, and 19 including the pipe symbol |. Such sets of coordinates add up quickly and may not fully fit into the request URL.
In instances like theses, it's better to compress the polyline to ensure it fits inside the URL.
Google requires you to use their own compression algorithm . Without getting into the specifics too much, it's good to know that you can:
- either use the
google.maps.geometry.encodingnamespace in the browser - or utilise the
@googlemaps/polyline-codecpackage in both the browser and NodeJS.
Assuming your geo points comprise a list of [lat, lng] pairs:
[
[48.26491, 16.398],
[48.26408, 16.39764],
[48.26313, 16.40098],
[48.26515, 16.40229],
[48.26607, 16.39792],
[48.26594, 16.39651],
[48.26561, 16.39609],
[48.26157, 16.39431],
[48.2603, 16.39292],
[48.25309, 16.37521],
[48.25213, 16.37348],
[48.25026, 16.37141],
[48.24789, 16.37002],
[48.24164, 16.36448],
[48.23578, 16.36089],
[48.23377, 16.36046],
[48.23211, 16.3609],
[48.22854, 16.36286],
[48.22576, 16.3662],
[48.22482, 16.36694],
[48.22108, 16.36742],
[48.21926, 16.36808],
[48.21585, 16.36069],
[48.21437, 16.35926],
[48.21425, 16.35734],
[48.21186, 16.35661],
[48.21177, 16.3556],
[48.20864, 16.35494],
[48.20677, 16.3557],
[48.20567, 16.3571],
[48.20529, 16.35223],
[48.20543, 16.34915],
[48.20424, 16.34894],
[48.19987, 16.34928],
[48.19973, 16.34814],
]; You can encode this polyline with:
import { encode } from '@googlemaps/polyline-codec';
const points = [...];
const encodedPath = encode(points);
// returns "uvqeHovacBdDfA|D{SsKeGwDhZXxG`ArAfXbJ|FtG`l@tmB~DxItJ|KxMtG`f@ra@rc@lUpKtAjIwAhUgKjP{SzDsCjV_BjJcChTdm@fH|GV~J|MpCPhEpRbCtJwCzEwGjAl][fRlFh@hZcAZbF" To pass this encoded polyline to the Static Map endpoint, you'd prefix the path with enc::
path=color:0x00000078|weight:4|enc:uvqeHovacBdDfA|D{SsKeGwDhZXxG`ArAfXbJ|FtG`l@tmB~DxItJ|KxMtG`f@ra@rc@lUpKtAjIwAhUgKjP{SzDsCjV_BjJcChTdm@fH|GV~J|MpCPhEpRbCtJwCzEwGjAl][fRlFh@hZcAZbF
^^^^ which is equivalent to the traditional, much longer string of:
path=color:0x00000078|weight:4|48.26491,16.398|48.26408,16.39764|48.26313,16.40098|48.26515,16.40229|48.26607,16.39792|48.26594,16.39651|48.26561,16.39609|48.26157,16.39431|48.2603,16.39292|48.25309,16.37521|48.25213,16.37348|48.25026,16.37141|48.24789,16.37002|48.24164,16.36448|48.23578,16.36089|48.23377,16.36046|48.23211,16.3609|48.22854,16.36286|48.22576,16.3662|48.22482,16.36694|48.22108,16.36742|48.21926,16.36808|48.21585,16.36069|48.21437,16.35926|48.21425,16.35734|48.21186,16.35661|48.21177,16.3556|48.20864,16.35494|48.20677,16.3557|48.20567,16.3571|48.20529,16.35223|48.20543,16.34915|48.20424,16.34894|48.19987,16.34928|48.19973,16.34814 import { encode } from "@googlemaps/polyline-codec";
const key = "AIza...";
const base = "https://maps.googleapis.com/maps/api/staticmap?";
const generateStaticMapURL = ({
origin,
destination,
polyLineCoords,
}: {
origin: google.maps.LatLngLiteral;
destination: google.maps.LatLngLiteral;
polyLineCoords: number[][];
}) => {
const q = [
"size=312x358",
`markers=|anchor:center|icon:https://storage.googleapis.com/gmaps-handbook/public/common/6/6.2/circle-8px.png|${origin.lat},${origin.lng}`,
`markers=|anchor:bottom|icon:https://storage.googleapis.com/gmaps-handbook/public/common/6/6.2/google-maps-pin-wikimedia.png|${destination.lat},${destination.lng}`,
`path=color:0x00000078|weight:4|enc:${encode(polyLineCoords)}`,
"map_id=cf19af61093c176a",
];
return base + q.join("&") + `&key=${key}`;
};
// usage
const imageURL = generateStaticMapURL({
origin: { lat: 48.26491, lng: 16.398 },
destination: { lat: 48.19974, lng: 16.34814 },
polyLineCoords: [
[48.26491, 16.398],
[48.26408, 16.39764],
[48.26313, 16.40098],
[48.26515, 16.40229],
[48.26607, 16.39792],
[48.26594, 16.39651],
[48.26561, 16.39609],
[48.26157, 16.39431],
[48.2603, 16.39292],
[48.25309, 16.37521],
[48.25213, 16.37348],
[48.25026, 16.37141],
[48.24789, 16.37002],
[48.24164, 16.36448],
[48.23578, 16.36089],
[48.23377, 16.36046],
[48.23211, 16.3609],
[48.22854, 16.36286],
[48.22576, 16.3662],
[48.22482, 16.36694],
[48.22108, 16.36742],
[48.21926, 16.36808],
[48.21585, 16.36069],
[48.21437, 16.35926],
[48.21425, 16.35734],
[48.21186, 16.35661],
[48.21177, 16.3556],
[48.20864, 16.35494],
[48.20677, 16.3557],
[48.20567, 16.3571],
[48.20529, 16.35223],
[48.20543, 16.34915],
[48.20424, 16.34894],
[48.19987, 16.34928],
[48.19973, 16.34814],
],
});
// returns
// https://maps.googleapis.com/maps/api/staticmap?size=312x358&markers=|anchor:center|icon:https://storage.googleapis.com/gmaps-handbook/public/common/6/6.2/circle-8px.png|48.26491,16.398&markers=|anchor:bottom|icon:https://storage.googleapis.com/gmaps-handbook/public/common/6/6.2/google-maps-pin-wikimedia.png|48.19974,16.34814&path=color:0x00000078|weight:4|enc:uvqeHovacBdDfA|D{SsKeGwDhZXxG`ArAfXbJ|FtG`l@tmB~DxItJ|KxMtG`f@ra@rc@lUpKtAjIwAhUgKjP{SzDsCjV_BjJcChTdm@fH|GV~J|MpCPhEpRbCtJwCzEwGjAl][fRlFh@hZcAZbF&map_id=cf19af61093c176a&key=AIza... You could display two variants of this static map โ light/dark โ depending on the time of the day the trip took place. You'd then simply swap the map_id as well as the colors of the path & markers.
If your Maps Static API usage will exceed 25,000 requests per day, you'll need to โsignโ the request URLs. More info here .
Due to Google's icon restriction of 64x64px, it is not possible to directly create a static map like this one:
Custom static map, courtesy of Garages Near Me
So how was the above map created?
In short, it's a supercomposition of two images: a static google basemap and a semi-transparent, centrally-positioned PNG logo.
jimp , the image manipulation library, exposes the handy composite utility and the implementation goes something like this:
import { BLEND_SOURCE_OVER, MIME_PNG, read } from "jimp";
import { gnmLogoWithBufferB64 } from "./logo";
export const generateStaticMapImageBuffer = async (latLng) => {
const url = [
"https://maps.googleapis.com/maps/api/staticmap",
`?center=${latLng?.lat},${latLng?.lng}`,
"&zoom=15",
// to achieve 750x500 because the default limit is 640x640px
// https://developers.google.com/maps/documentation/maps-static/start#Largerimagesizes
"&size=375x250&scale=2",
`&key=${key}`,
`&map_id=${map_id}`,
].join("");
// read Google's image in
const img = await read(url);
// read the logo in (as a base64 image)
const iconImg = await read(Buffer.from(gnmLogoWithBufferB64, "base64"));
// stitch the layers onto the map canvas
img.composite(iconImg, 375 - 200 / 2, 250 - 200 / 2, {
mode: BLEND_SOURCE_OVER,
opacitySource: 1,
opacityDest: 1,
});
// return a PNG
return await img.getBufferAsync(MIME_PNG);
}; The core idea here is to let Google handle as many of the geometry and lat/lng โ x,y image conversions as possible. Though, you could very well draw a polyline yourself on the jimp canvas by:
- either converting lat/lng into x,y as shown in Bonus: Positioning a fully custom DOM tooltip
- or employing
proj4as discussed in Coordinate conversions .
Done! You're now able to create outstanding branded static maps.
In the next chapter we'll dive into the nuances of geocoding .