MapLibre GL JS 6.18
Blend modes
From version 6.18, MapLibre GL JS can blend a fill, line or raster layer into the map beneath it, the way layers blend in CSS and design tools. Land cover keeps the relief beneath it, night falls over the globe, streets glow, and old maps print onto new ones. Every map here is live, with its code.
Loading the map…
- multiply
- multiply
- plus-lighter
- overlay
- screen
- plus-lighter
- color
- multiply
Land cover that keeps the relief
A hillshade usually goes on top of the land cover, half see-through, and grays out its colors. Here it goes underneath, and the land cover is multiplied onto it, like ink on paper. Forests, meadows and glaciers keep their colors, and every ridge keeps its shadow, on 3D terrain too.
- A hillshade underneath
- 3D terrain
- OpenFreeMap land cover
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
// The land cover's colors, by OpenMapTiles class.
const landColor: maplibregl.ExpressionSpecification = [
"match",
["get", "class"],
"wood",
"#9fc68a",
"grass",
"#d3e6ad",
"farmland",
"#eee6b8",
"wetland",
"#c4ddd0",
"sand",
"#f0e3b5",
"rock",
"#e2dcd2",
"ice",
"#e6f1f6",
"#dfe9c8",
];
export function createMap(container: string | HTMLElement) {
return new maplibregl.Map({
container,
center: [7.98, 46.56],
zoom: 11.9,
pitch: 60,
bearing: -20,
style: {
version: 8,
glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
sources: {
openmaptiles: { type: "vector", url: "https://tiles.openfreemap.org/planet" },
// Mapterhorn's elevation twice, once to shade and once to lift the map into 3D.
relief: { type: "raster-dem", url: "https://tiles.mapterhorn.com/tilejson.json" },
terrain: { type: "raster-dem", url: "https://tiles.mapterhorn.com/tilejson.json" },
},
terrain: { source: "terrain", exaggeration: 1.2 },
layers: [
{ id: "land", type: "background", paint: { "background-color": "#f5f2ea" } },
// The relief comes first, under everything else.
{
id: "hillshade",
type: "hillshade",
source: "relief",
paint: {
"hillshade-method": "igor",
"hillshade-shadow-color": "#2f3b4d",
"hillshade-highlight-color": "#ffffff",
},
},
// Land cover on top of it, multiplied. Like ink on paper, it tints the relief instead of hiding it.
{
id: "landcover",
type: "fill",
source: "openmaptiles",
"source-layer": "landcover",
paint: { "fill-color": landColor, "fill-layer-blend": "multiply" },
},
{
id: "landuse",
type: "fill",
source: "openmaptiles",
"source-layer": "landuse",
filter: [
"in",
["get", "class"],
["literal", ["residential", "suburb", "village", "commercial", "industrial"]],
],
paint: { "fill-color": "#e9dccf", "fill-layer-blend": "multiply" },
},
// Lakes and rivers are flat, so they cover the relief.
{
id: "water",
type: "fill",
source: "openmaptiles",
"source-layer": "water",
paint: { "fill-color": "#9cc9e6" },
},
{
id: "rivers",
type: "line",
source: "openmaptiles",
"source-layer": "waterway",
paint: {
"line-color": "#9cc9e6",
"line-width": ["interpolate", ["linear"], ["zoom"], 10, 0.6, 15, 3],
},
},
{
id: "roads",
type: "line",
source: "openmaptiles",
"source-layer": "transportation",
filter: [
"in",
["get", "class"],
["literal", ["motorway", "trunk", "primary", "secondary", "tertiary", "minor"]],
],
paint: {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 10, 0.6, 15, 4],
},
},
{
id: "railways",
type: "line",
source: "openmaptiles",
"source-layer": "transportation",
filter: ["==", ["get", "class"], "rail"],
paint: { "line-color": "#7a6f66", "line-width": 1, "line-dasharray": [3, 2] },
},
{
id: "peaks",
type: "symbol",
source: "openmaptiles",
"source-layer": "mountain_peak",
filter: ["<=", ["get", "rank"], 3],
layout: {
"text-field": [
"format",
["get", "name"],
{},
"\n",
{},
["concat", ["get", "ele"], " m"],
{ "font-scale": 0.85 },
],
"text-font": ["Noto Sans Italic"],
"text-size": 11,
},
paint: {
"text-color": "#4b3a28",
"text-halo-color": "rgba(255, 255, 255, 0.75)",
"text-halo-width": 1.2,
},
},
{
id: "places",
type: "symbol",
source: "openmaptiles",
"source-layer": "place",
filter: ["in", ["get", "class"], ["literal", ["city", "town", "village"]]],
layout: {
"text-field": ["get", "name"],
"text-font": ["Noto Sans Regular"],
"text-size": ["match", ["get", "class"], "village", 11, 13],
},
paint: {
"text-color": "#22262e",
"text-halo-color": "rgba(255, 255, 255, 0.85)",
"text-halo-width": 1.5,
},
},
],
},
});
}Night falls on the globe, right now
Sentinel-2's cloudless Earth, with the night side as five circles of darkness from sunset to the end of twilight, multiplied onto it so the land stays visible in the dark. Natural Earth's cities add their light in plus-lighter as their sky darkens. The night moves on every minute.
- Globe
- Computed every minute
- Two modes
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
// Natural Earth's built-up areas, which light up after sunset.
const urbanAreas =
"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@v5.1.2/geojson/ne_50m_urban_areas.geojson";
const RAD = Math.PI / 180;
type LngLat = [number, number];
/** Where the Sun is overhead at `date`, with the Astronomical Almanac's low-precision formulas. */
function subsolarPoint(date: Date): LngLat {
const d = (date.getTime() - Date.UTC(2000, 0, 1, 12)) / 86_400_000;
const anomaly = (357.529 + 0.98560028 * d) * RAD;
const longitude =
(280.459 + 0.98564736 * d + 1.915 * Math.sin(anomaly) + 0.02 * Math.sin(2 * anomaly)) * RAD;
const obliquity = (23.439 - 0.00000036 * d) * RAD;
const ascension =
Math.atan2(Math.cos(obliquity) * Math.sin(longitude), Math.cos(longitude)) / RAD;
const declination = Math.asin(Math.sin(obliquity) * Math.sin(longitude)) / RAD;
const siderealTime = 280.46061837 + 360.98564736629 * d;
return [((((ascension - siderealTime) % 360) + 540) % 360) - 180, declination];
}
/** How high the Sun stands over `place`, in degrees, negative below the horizon. */
function sunAltitude([lng, lat]: LngLat, [sunLng, sunLat]: LngLat): number {
const cosine =
Math.sin(lat * RAD) * Math.sin(sunLat * RAD) +
Math.cos(lat * RAD) * Math.cos(sunLat * RAD) * Math.cos((lng - sunLng) * RAD);
return 90 - Math.acos(Math.max(-1, Math.min(1, cosine))) / RAD;
}
/** Where the Sun is more than `depth` degrees below the horizon, a circle around the point opposite it. */
function darkness([sunLng, sunLat]: LngLat, depth: number): GeoJSON.Feature {
const lng0 = (sunLng > 0 ? sunLng - 180 : sunLng + 180) * RAD;
const lat0 = -sunLat * RAD;
const radius = (90 - depth) * RAD;
const ring: LngLat[] = [];
for (let bearing = 0; bearing <= 360; bearing += 2) {
const b = bearing * RAD;
const lat = Math.asin(
Math.sin(lat0) * Math.cos(radius) + Math.cos(lat0) * Math.sin(radius) * Math.cos(b),
);
let lng =
(lng0 +
Math.atan2(
Math.sin(b) * Math.sin(radius) * Math.cos(lat0),
Math.cos(radius) - Math.sin(lat0) * Math.sin(lat),
)) /
RAD;
// Keep the ring unbroken across the antimeridian.
if (ring.length > 0) lng += 360 * Math.round((ring[ring.length - 1][0] - lng) / 360);
ring.push([lng, lat / RAD]);
}
// A circle around a pole goes once around the world, so close it over that pole.
const [first, last] = [ring[0], ring[ring.length - 1]];
if (Math.abs(last[0] - first[0]) > 180) {
const pole = lat0 > 0 ? 90 : -90;
ring.push([last[0], pole], [first[0], pole], first);
}
// Center the ring on the antimeridian's near side, as MapLibre wraps only one world either way.
const lngs = ring.map(([lng]) => lng);
const shift = 360 * Math.round((Math.min(...lngs) + Math.max(...lngs)) / 720);
for (const point of ring) point[0] -= shift;
return {
type: "Feature",
properties: { depth },
geometry: { type: "Polygon", coordinates: [ring] },
};
}
export function createMap(container: string | HTMLElement) {
const sun = subsolarPoint(new Date());
const map = new maplibregl.Map({
container,
// Where the evening is, a quarter turn east of the Sun.
center: [sun[0] + 100, 18],
zoom: 2.5,
style: {
version: 8,
projection: { type: "globe" },
sky: { "atmosphere-blend": 1 },
sources: {
sentinel2: {
type: "raster",
tiles: [
"https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless_3857/default/g/{z}/{y}/{x}.jpg",
],
tileSize: 256,
maxzoom: 14,
attribution:
'<a href="https://s2maps.eu">Sentinel-2 cloudless</a> by <a href="https://eox.at">EOX IT Services GmbH</a> (Contains modified Copernicus Sentinel data 2016 & 2017)',
},
night: { type: "geojson", data: { type: "FeatureCollection", features: [] } },
cities: {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
attribution: '<a href="https://www.naturalearthdata.com/">Natural Earth</a>',
},
},
layers: [
{ id: "imagery", type: "raster", source: "sentinel2" },
// Five circles of darkness over each other, from sunset to the end of twilight. Multiplied, they
// dim the image as night falls instead of covering it.
{
id: "night",
type: "fill",
source: "night",
paint: {
"fill-color": "rgba(10, 16, 52, 0.4)",
"fill-antialias": false,
"fill-layer-blend": "multiply",
},
},
// The cities add their light to the image, with a soft glow around them. Black adds nothing, so
// they shine only where it's dark.
{
id: "glow",
type: "line",
source: "cities",
paint: {
"line-color": ["interpolate", ["linear"], ["get", "dark"], 0, "#000000", 1, "#8a4a12"],
"line-width": 6,
"line-blur": 6,
"line-layer-blend": "plus-lighter",
},
},
{
id: "lights",
type: "fill",
source: "cities",
paint: {
"fill-color": ["interpolate", ["linear"], ["get", "dark"], 0, "#000000", 1, "#ffc45e"],
"fill-layer-blend": "plus-lighter",
},
},
],
},
});
let cities: GeoJSON.FeatureCollection<GeoJSON.Polygon | GeoJSON.MultiPolygon> | undefined;
function update() {
const sun = subsolarPoint(new Date());
const night = [0, 4, 8, 12, 16].map((depth) => darkness(sun, depth));
void map
.getSource<maplibregl.GeoJSONSource>("night")
?.setData({ type: "FeatureCollection", features: night });
if (!cities) return;
// How dark it is over each city, from 0 at sunset to 1 once the Sun is 12 degrees down.
for (const city of cities.features) {
const polygon =
city.geometry.type === "Polygon" ? city.geometry.coordinates : city.geometry.coordinates[0];
const altitude = sunAltitude(polygon[0][0] as LngLat, sun);
city.properties = { dark: Math.max(0, Math.min(1, -altitude / 12)) };
}
void map.getSource<maplibregl.GeoJSONSource>("cities")?.setData(cities);
}
map.on("load", async () => {
update();
cities = await (await fetch(urbanAreas)).json();
update();
});
// The night moves on, a quarter of a degree a minute.
const timer = setInterval(update, 60_000);
map.on("remove", () => clearInterval(timer));
return map;
}London's buildings, colored by land use
A gray map of the East End, with land use on top in the overlay mode. Overlay keeps a middle gray's darkness and a light gray's lightness, so the colors land on the buildings, which are a middle gray, while the streets and yards stay white. It's the look of Charles Booth's poverty maps, from one fill layer.
- Color only on buildings
- OpenFreeMap land use
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
// Land use by OpenMapTiles class, in colors after Charles Booth's poverty map of London.
const landUse: maplibregl.ExpressionSpecification = [
"match",
["get", "class"],
"residential",
"#e0a526",
["commercial", "retail"],
"#d6403a",
"industrial",
"#3b5bb5",
"railway",
"#5a5f6e",
["school", "college", "university", "hospital"],
"#c2569b",
"#7a9a3a",
];
export function createMap(container: string | HTMLElement) {
return new maplibregl.Map({
container,
center: [-0.0715, 51.5175],
zoom: 15.2,
style: {
version: 8,
glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
sources: {
openmaptiles: { type: "vector", url: "https://tiles.openfreemap.org/planet" },
},
layers: [
// A map in grays, with nearly white streets and open ground and buildings in a middle gray.
{ id: "ground", type: "background", paint: { "background-color": "#f3f1ec" } },
{
id: "water",
type: "fill",
source: "openmaptiles",
"source-layer": "water",
paint: { "fill-color": "#c3cdd3" },
},
{
id: "buildings",
type: "fill",
source: "openmaptiles",
"source-layer": "building",
paint: { "fill-color": "#808080" },
},
// Land use over it in overlay. Over the middle gray it shows its own color, and over the light
// ground it stays nearly white, so the colors land on the buildings and not the streets.
{
id: "landuse",
type: "fill",
source: "openmaptiles",
"source-layer": "landuse",
paint: { "fill-color": landUse, "fill-layer-blend": "overlay" },
},
{
id: "street-names",
type: "symbol",
source: "openmaptiles",
"source-layer": "transportation_name",
layout: {
"symbol-placement": "line",
"text-field": ["get", "name"],
"text-font": ["Noto Sans Regular"],
"text-size": 11,
},
paint: { "text-color": "#3a3a3a", "text-halo-color": "#f3f1ec", "text-halo-width": 1.5 },
},
],
},
});
}Berlin within reach of the U-Bahn and S-Bahn
600 meters around every station, about seven minutes on foot, in blue for the U-Bahn and green for the S-Bahn, both in screen over a dark map. Screen only lightens, so the streets show through, and where both networks reach, the two colors light up to cyan.
- A dark basemap
- OpenStreetMap stations
- Colors that add up
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
// Berlin's U-Bahn and S-Bahn stations, from OpenStreetMap.
import stations from "./berlin-stations.geojson?url";
/** A circle of `meters` around a point, as a polygon. */
function circle([lng, lat]: GeoJSON.Position, meters: number): GeoJSON.Feature {
const dLat = meters / 111_320;
const dLng = dLat / Math.cos((lat * Math.PI) / 180);
const ring = Array.from({ length: 65 }, (_, i) => {
const angle = (i / 64) * 2 * Math.PI;
return [lng + dLng * Math.cos(angle), lat + dLat * Math.sin(angle)];
});
return { type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [ring] } };
}
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: "https://tiles.openfreemap.org/styles/dark",
center: [13.4, 52.505],
zoom: 10.6,
});
map.on("load", async () => {
const all: GeoJSON.FeatureCollection<GeoJSON.Point> = await (await fetch(stations)).json();
map.addSource("stations", { type: "geojson", data: all });
// 600 meters as the crow flies around each station, about seven minutes on foot, in each network's
// color. Over the streets and under their names, in screen. On a dark map it only lightens, so the
// streets show through, and where both networks reach, blue and green light up to cyan.
for (const [network, color] of [
["U-Bahn", "#1d4fa6"],
["S-Bahn", "#1e8c3a"],
]) {
const reach = all.features
.filter((station) => station.properties?.network === network)
.map((station) => circle(station.geometry.coordinates, 600));
map.addSource(network, {
type: "geojson",
data: { type: "FeatureCollection", features: reach },
});
map.addLayer(
{
id: network,
type: "fill",
source: network,
paint: { "fill-color": color, "fill-layer-blend": "screen" },
},
"highway_name_other",
);
}
map.addLayer({
id: "stations",
type: "circle",
source: "stations",
paint: {
"circle-color": "#ffffff",
"circle-radius": ["interpolate", ["linear"], ["zoom"], 10, 1.2, 15, 4],
},
});
});
return map;
}Barcelona's streets as light
Sentinel-2's daytime image, dimmed to night, and every street drawn twice, as a wide, soft glow and as a bright core. In plus-lighter both add their light to the image instead of covering it, so the city shows through and the cores burn white where they meet the glow.
- Raster imagery
- Two line layers
- Additive light
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
const streets: maplibregl.FilterSpecification = [
"in",
["get", "class"],
["literal", ["motorway", "trunk", "primary", "secondary", "tertiary", "minor", "service"]],
];
// How wide each class of road is, relative to a main road.
const roadClass: maplibregl.ExpressionSpecification = [
"match",
["get", "class"],
["motorway", "trunk"],
1.6,
["primary", "secondary"],
1.1,
["tertiary", "minor"],
0.55,
0.3,
];
// `base` pixels for a main road at zoom 10, and nine times that at zoom 16.
const width = (base: number): maplibregl.ExpressionSpecification => [
"interpolate",
["exponential", 1.6],
["zoom"],
10,
["*", base, roadClass],
16,
["*", base * 9, roadClass],
];
export function createMap(container: string | HTMLElement) {
return new maplibregl.Map({
container,
center: [2.165, 41.392],
zoom: 12.6,
style: {
version: 8,
sources: {
sentinel2: {
type: "raster",
tiles: [
"https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless_3857/default/g/{z}/{y}/{x}.jpg",
],
tileSize: 256,
maxzoom: 14,
attribution:
'<a href="https://s2maps.eu">Sentinel-2 cloudless</a> by <a href="https://eox.at">EOX IT Services GmbH</a> (Contains modified Copernicus Sentinel data 2016 & 2017)',
},
openmaptiles: { type: "vector", url: "https://tiles.openfreemap.org/planet" },
},
layers: [
// Daytime imagery, dimmed to night.
{
id: "imagery",
type: "raster",
source: "sentinel2",
paint: {
"raster-brightness-max": 0.36,
"raster-saturation": -0.5,
"raster-contrast": 0.1,
},
},
// Every street adds light to the image, and where the glow and the street meet they add up to white.
{
id: "glow",
type: "line",
source: "openmaptiles",
"source-layer": "transportation",
filter: streets,
paint: {
"line-color": "#b4500c",
"line-width": width(6),
"line-blur": width(5),
"line-layer-blend": "plus-lighter",
},
},
{
id: "streets",
type: "line",
source: "openmaptiles",
"source-layer": "transportation",
filter: streets,
paint: {
"line-color": "#ffb45c",
"line-width": width(0.7),
"line-layer-blend": "plus-lighter",
},
},
],
},
});
}Countries tinted onto a relief
A gray relief of the Alps, with Natural Earth's countries on top of it in the color mode. Each country gives its hue and saturation, and the relief gives the light and shade. One fill layer turns a hillshade into a political relief map.
- A gray relief
- GeoJSON countries
- Seven colors, no neighbors alike
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
// Natural Earth's countries. MAPCOLOR7 gives each one of seven colors, with no two neighbors alike.
const countries =
"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@v5.1.2/geojson/ne_50m_admin_0_countries.geojson";
export function createMap(container: string | HTMLElement) {
return new maplibregl.Map({
container,
center: [10.4, 46.3],
zoom: 5.9,
style: {
version: 8,
glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
sources: {
openmaptiles: { type: "vector", url: "https://tiles.openfreemap.org/planet" },
terrain: { type: "raster-dem", url: "https://tiles.mapterhorn.com/tilejson.json" },
countries: {
type: "geojson",
data: countries,
attribution: '<a href="https://www.naturalearthdata.com/">Natural Earth</a>',
},
},
layers: [
// A gray relief, the land in mid gray shaded by the hillshade.
{ id: "land", type: "background", paint: { "background-color": "#9d9d9d" } },
{
id: "hillshade",
type: "hillshade",
source: "terrain",
paint: {
"hillshade-method": "igor",
"hillshade-shadow-color": "#000000",
"hillshade-highlight-color": "#ffffff",
},
},
// The countries keep their hue and saturation, and take their lightness from the relief.
{
id: "countries",
type: "fill",
source: "countries",
paint: {
"fill-color": [
"match",
["get", "MAPCOLOR7"],
1,
"#e2603a",
2,
"#e9b23c",
3,
"#78b551",
4,
"#2f9e95",
5,
"#4a78c9",
6,
"#9a5cc6",
"#d35a8f",
],
"fill-layer-blend": "color",
},
},
{
id: "water",
type: "fill",
source: "openmaptiles",
"source-layer": "water",
paint: { "fill-color": "#cfe0e8" },
},
{
id: "borders",
type: "line",
source: "openmaptiles",
"source-layer": "boundary",
filter: ["all", ["==", ["get", "admin_level"], 2], ["!=", ["get", "maritime"], 1]],
paint: { "line-color": "#ffffff", "line-width": 1.2, "line-opacity": 0.8 },
},
{
id: "country-names",
type: "symbol",
source: "openmaptiles",
"source-layer": "place",
filter: ["==", ["get", "class"], "country"],
layout: {
"text-field": ["get", "name:en"],
"text-font": ["Noto Sans Bold"],
"text-size": 11,
"text-transform": "uppercase",
"text-letter-spacing": 0.12,
},
paint: {
"text-color": "#1f2430",
"text-halo-color": "rgba(255, 255, 255, 0.7)",
"text-halo-width": 1.2,
},
},
],
},
});
}Two walks through Rome, in highlighter
Two routes drawn on top of the whole map, labels included. Multiplied, they tint it like a highlighter on paper. The street names stay dark and sharp, and where the walks cross at the Pantheon, yellow and pink make orange.
- On top of the labels
- GeoJSON lines
- Colors that mix
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
// Two walks through Rome, routed on OpenStreetMap's footpaths.
import walks from "./rome-walks.geojson?url";
// A marker's stroke, wider as the map zooms in.
const stroke = (color: string): maplibregl.LineLayerSpecification["paint"] => ({
"line-color": color,
"line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 6, 17, 48],
"line-layer-blend": "multiply",
});
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: "https://tiles.openfreemap.org/styles/liberty",
center: [12.4745, 41.9005],
zoom: 14.4,
});
map.on("load", () => {
map.addSource("walks", { type: "geojson", data: walks });
// On top of the whole map, labels too. Multiplied, the strokes tint it like a highlighter on paper.
// The street names stay dark and sharp, and where the two walks cross, yellow and pink make orange.
for (const [day, color] of [
[1, "#fff23a"],
[2, "#ff8fd0"],
] as const) {
map.addLayer({
id: `day-${day}`,
type: "line",
source: "walks",
filter: ["==", ["get", "day"], day],
layout: { "line-cap": "round", "line-join": "round" },
paint: stroke(color),
});
}
});
return map;
}Raster layers blend too
With raster-layer-blend, image tiles blend into the map too. Shaded relief, aerial photos, old maps and satellite data no longer have to cover the map or fade over it.
Loading the map…
- multiply
- multiply
- luminosity
- multiply
- screen
- multiply
- color
- multiply
Tyrol's mountains, shaded into the map
Mapping agencies publish their shaded relief as image tiles, and today such a raster can only fade over a map, which grays it out. Multiplied, basemap.at's relief of Austria only darkens the map, most where the slopes face away from the light, so Innsbruck's streets, forests and meadows keep their colors.
- Relief image tiles
- Under the labels
- basemap.at
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: "https://tiles.openfreemap.org/styles/liberty",
center: [11.4, 47.28],
zoom: 11.4,
});
map.on("load", () => {
// Austria's shaded relief from basemap.at, as gray image tiles.
map.addSource("relief", {
type: "raster",
tiles: ["https://mapsneu.wien.gv.at/basemap/bmapgelaende/grau/google3857/{z}/{y}/{x}.jpeg"],
tileSize: 256,
maxzoom: 17,
attribution: '<a href="https://basemap.at">basemap.at</a>',
});
// Over the roads and under the labels, multiplied into the map. White leaves the map as it is and the
// shadows darken it, so the relief shows without graying the colors out.
map.addLayer(
{
id: "relief",
type: "raster",
source: "relief",
paint: { "raster-layer-blend": "multiply" },
},
"waterway_line_label",
);
});
return map;
}Where the slopes get steep, on the national map
swisstopo's map of slopes steeper than 30 degrees, the ones that matter for avalanches, from yellow through orange, red and violet to gray as they steepen. Multiplied onto the national map in gray, the colors print over it like ink, and its contours, rocks and names stay sharp beneath them.
- Two rasters
- swisstopo
- Ski touring
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
/** A layer of swisstopo's maps, as image tiles. */
const swisstopo = (
layer: string,
format: "jpeg" | "png",
): maplibregl.RasterSourceSpecification => ({
type: "raster",
tiles: [`https://wmts.geo.admin.ch/1.0.0/${layer}/default/current/3857/{z}/{x}/{y}.${format}`],
tileSize: 256,
maxzoom: 17,
attribution: '<a href="https://www.swisstopo.admin.ch">swisstopo</a>',
});
export function createMap(container: string | HTMLElement) {
return new maplibregl.Map({
container,
center: [9.8, 46.83],
zoom: 14.6,
style: {
version: 8,
sources: {
topo: swisstopo("ch.swisstopo.pixelkarte-grau", "jpeg"),
slopes: swisstopo("ch.swisstopo.hangneigung-ueber_30", "png"),
},
layers: [
// The national map in gray, with its contours, rocks and names.
{ id: "topo", type: "raster", source: "topo" },
// Slopes steeper than 30 degrees, from yellow through orange, red and violet to gray as they steepen.
// Multiplied, the colors print over the map, so the contours and names beneath stay sharp.
{
id: "slopes",
type: "raster",
source: "slopes",
paint: { "raster-layer-blend": "multiply" },
},
],
},
});
}Amsterdam in map colors, from the air
A map in flat colors, with an aerial photo on top in the luminosity mode. The photo gives the light and shade, and the map gives the colors, so the canals stay blue and the roofs warm, with the trees, boats and roofs of the photo.
- 8 cm aerial photo
- A custom map
- Labels on top
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
export function createMap(container: string | HTMLElement) {
return new maplibregl.Map({
container,
center: [4.8885, 52.3705],
zoom: 15.9,
style: {
version: 8,
glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
sources: {
openmaptiles: { type: "vector", url: "https://tiles.openfreemap.org/planet" },
// The Netherlands from the air, at 8 centimeters a pixel.
photo: {
type: "raster",
tiles: [
"https://service.pdok.nl/hwh/luchtfotorgb/wmts/v1_0/Actueel_orthoHR/EPSG:3857/{z}/{x}/{y}.jpeg",
],
tileSize: 256,
maxzoom: 19,
attribution: '<a href="https://www.beeldmateriaal.nl">Beeldmateriaal Nederland</a>',
},
},
layers: [
// A map in flat, light colors.
{ id: "land", type: "background", paint: { "background-color": "#efe3cb" } },
// Parks, and the canal ring, which is a World Heritage site, so the water goes on top.
{
id: "green",
type: "fill",
source: "openmaptiles",
"source-layer": "park",
paint: { "fill-color": "#9fd67e" },
},
{
id: "water",
type: "fill",
source: "openmaptiles",
"source-layer": "water",
paint: { "fill-color": "#4f9be0" },
},
{
id: "streets",
type: "line",
source: "openmaptiles",
"source-layer": "transportation",
paint: { "line-color": "#f4efe6", "line-width": 9 },
},
{
id: "buildings",
type: "fill",
source: "openmaptiles",
"source-layer": "building",
paint: { "fill-color": "#dcb59a" },
},
// The photo gives its light and shade, the map keeps its hue and saturation. Brightened a little first,
// since a photo from the air is darker than a map.
{
id: "photo",
type: "raster",
source: "photo",
paint: { "raster-brightness-min": 0.2, "raster-layer-blend": "luminosity" },
},
{
id: "street-names",
type: "symbol",
source: "openmaptiles",
"source-layer": "transportation_name",
layout: {
"symbol-placement": "line",
"text-field": ["get", "name"],
"text-font": ["Noto Sans Regular"],
"text-size": 12,
},
paint: { "text-color": "#1f2533", "text-halo-color": "#ffffff", "text-halo-width": 1.5 },
},
],
},
});
}Zurich in 1900, over Zurich today
swisstopo's Siegfried map, as it stood in 1900, multiplied onto today's map. The old ink and today's colors both show, so the lake shore, the streets and the railways of 1900 sit on the city of today.
- A historical map
- swisstopo
- Today's map beneath
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: "https://tiles.openfreemap.org/styles/liberty",
center: [8.541, 47.372],
zoom: 13.9,
});
map.on("load", () => {
// Zurich on the Siegfried map, as swisstopo's surveyors drew it in 1900.
map.addSource("siegfried", {
type: "raster",
tiles: [
"https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hiks-siegfried/default/1900/3857/{z}/{x}/{y}.png",
],
tileSize: 256,
maxzoom: 17,
attribution: '<a href="https://www.swisstopo.admin.ch">swisstopo</a>',
});
// Printed over today's map, under its labels. Multiplied, the old ink and today's colors both show, so
// the streets, the lake shore and the railway of 1900 sit on the city of today.
map.addLayer(
{
id: "siegfried",
type: "raster",
source: "siegfried",
paint: { "raster-layer-blend": "multiply" },
},
"waterway_line_label",
);
});
return map;
}Europe's lights on a dark map
NASA's Black Marble, the Earth at night in 2016, in screen over a dark map. The black night adds nothing, so the map's coasts, borders and roads stay, and the cities light it up.
- Satellite imagery
- A dark basemap
- NASA
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: "https://tiles.openfreemap.org/styles/dark",
center: [9, 48.5],
zoom: 4.6,
});
map.on("load", () => {
// The Earth at night in 2016, from the VIIRS instrument, by NASA's Black Marble.
map.addSource("lights", {
type: "raster",
tiles: [
"https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_Black_Marble/default/2016-01-01/GoogleMapsCompatible_Level8/{z}/{y}/{x}.png",
],
tileSize: 256,
maxzoom: 8,
attribution: '<a href="https://earthdata.nasa.gov/gibs">NASA EOSDIS GIBS</a>',
});
// In screen the black night adds nothing, so the dark map stays, and the lights brighten it.
map.addLayer(
{
id: "lights",
type: "raster",
source: "lights",
paint: { "raster-layer-blend": "screen" },
},
"highway_name_other",
);
});
return map;
}The latest day's rain, tinted into the map
The rain of the latest day NASA has measured, from the GPM satellites, multiplied onto the map under its labels. The rain tints the land and the sea instead of covering them, like the weather maps avalanche.report asked for.
- Live data
- NASA
- Under the labels
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: "https://tiles.openfreemap.org/styles/liberty",
center: [-20, 40],
zoom: 2.4,
});
map.on("load", () => {
// The rain of the latest day NASA has measured, from the GPM satellites.
map.addSource("rain", {
type: "raster",
tiles: [
"https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/IMERG_Precipitation_Rate/default/default/GoogleMapsCompatible_Level6/{z}/{y}/{x}.png",
],
tileSize: 256,
maxzoom: 6,
attribution: '<a href="https://earthdata.nasa.gov/gibs">NASA EOSDIS GIBS</a>',
});
// Over the map and under its labels. Multiplied, the rain tints the land and sea instead of covering
// them, so coasts, borders and roads stay visible through it.
map.addLayer(
{
id: "rain",
type: "raster",
source: "rain",
paint: { "raster-layer-blend": "multiply" },
},
"waterway_line_label",
);
});
return map;
}The Gulf Stream's warm water, on the sea floor
The temperature of the sea on the latest day, in the color mode over a gray relief of the land and the sea floor. The temperature gives the hue, and the relief the light and shade.
- Live data
- Two rasters
- NASA
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
/** A layer of NASA's satellite maps, as image tiles. */
const gibs = (layer: string, time: string, level: number, format: "jpg" | "png") =>
({
type: "raster",
tiles: [
`https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/${layer}/default/${time}/GoogleMapsCompatible_Level${level}/{z}/{y}/{x}.${format}`,
],
tileSize: 256,
maxzoom: level,
attribution: '<a href="https://earthdata.nasa.gov/gibs">NASA EOSDIS GIBS</a>',
}) satisfies maplibregl.RasterSourceSpecification;
export function createMap(container: string | HTMLElement) {
return new maplibregl.Map({
container,
center: [-66, 38],
zoom: 3.8,
style: {
version: 8,
sources: {
// The relief of the land and the sea floor.
relief: gibs("BlueMarble_ShadedRelief_Bathymetry", "default", 8, "jpg"),
// The temperature of the sea on the latest day, from blue for cold to red for warm.
temperature: gibs("GHRSST_L4_MUR_Sea_Surface_Temperature", "default", 7, "png"),
},
layers: [
// In gray, and lighter, so the deep sea floor isn't black.
{
id: "relief",
type: "raster",
source: "relief",
paint: { "raster-saturation": -1, "raster-brightness-min": 0.35 },
},
// The temperature's hue and saturation, with the light and shade of the relief beneath, so the Gulf
// Stream's warm water shows over the shelf and the deep sea floor.
{
id: "temperature",
type: "raster",
source: "temperature",
paint: { "raster-layer-blend": "color" },
},
],
},
});
}A blend with a mask
MapLibre has no mask property, but a fill layer between two layers does the job. A white fill with round holes lies between a photo of Paris and the map, which is multiplied onto both. White is the color multiply leaves unchanged, so the map comes out as it is over the mask, and blends with the photo in the holes. For screen and plus-lighter the mask is black.
- A fill layer as the mask
- Two rasters
- Holes that stay in place
Map code
Plain MapLibre GL JS 6.18, ready to paste into a module. createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
const center: [number, number] = [2.34, 48.86];
// A polygon over the whole world, with a grid of round holes around the center.
const mask = [
[
[-180, -85],
[180, -85],
[180, 85],
[-180, 85],
[-180, -85],
],
];
const squash = Math.cos((center[1] * Math.PI) / 180);
for (let column = -10; column <= 10; column++) {
for (let row = -7; row <= 7; row++) {
const hole = [];
for (let i = 0; i <= 32; i++) {
const angle = (i / 32) * 2 * Math.PI;
hole.push([
center[0] + column * 0.03 + 0.011 * Math.cos(angle),
center[1] + (row * 0.03 + 0.011 * Math.sin(angle)) * squash,
]);
}
mask.push(hole);
}
}
/** A layer of EOX's maps, as image tiles. */
const eox = (layer: string, attribution: string): maplibregl.RasterSourceSpecification => ({
type: "raster",
tiles: [`https://tiles.maps.eox.at/wmts/1.0.0/${layer}/default/g/{z}/{y}/{x}.jpg`],
tileSize: 256,
maxzoom: 14,
attribution,
});
export function createMap(container: string | HTMLElement) {
return new maplibregl.Map({
container,
center,
zoom: 12,
style: {
version: 8,
sources: {
photo: eox(
"s2cloudless_3857",
'<a href="https://s2maps.eu">Sentinel-2 cloudless</a> by <a href="https://eox.at">EOX IT Services GmbH</a> (Contains modified Copernicus Sentinel data 2016 & 2017)',
),
mask: { type: "geojson", data: { type: "Polygon", coordinates: mask } },
map: eox(
"osm_3857",
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
),
},
layers: [
// Drawn as a normal layer.
{ id: "photo", type: "raster", source: "photo" },
// White is the color multiply leaves unchanged, so the map comes out as it is over the mask.
{ id: "mask", type: "fill", source: "mask", paint: { "fill-color": "#ffffff" } },
// Blends with everything beneath it, the photo in the holes and the mask everywhere else.
{ id: "map", type: "raster", source: "map", paint: { "raster-layer-blend": "multiply" } },
],
},
});
}Seven modes
The same seven as CSS mix-blend-mode, with the same formulas. The swatches are CSS too, an orange disc in each mode over a colored ramp.
normal
Paints the layer over the map, as before.
Everything that doesn't blend.
multiply
Multiplies the colors. It only darkens, so what's beneath shows through, like ink on paper.
Hillshades, highlighters, overprinted maps.
ReliefDay and nightHighlighterShaded reliefSteep slopesThen and nowRainMask
screen
The inverse of multiply. It only lightens, like two slides projected on one screen.
Glows, haze, color on dark maps.
overlay
Multiplies where the map is dark and screens where it's light, which adds contrast.
Tints that keep the texture beneath.
color
The layer's hue and saturation, with the lightness of the map beneath.
Coloring a hillshade or a gray image.
luminosity
The layer's lightness, with the hue and saturation of the map beneath. The inverse of color.
Shading a colorful map.
plus-lighter
Adds the colors together, like light. Overlaps glow toward white.
Glows, lights, heat.
How it works
One paint property per layer.
Step 1
Pick a mode
Set fill-layer-blend, line-layer-blend or raster-layer-blend in the paint of a fill, line or raster layer, to "multiply", "screen", "overlay", "color", "luminosity" or "plus-lighter". Leave it out, or say "normal", for the way layers have always drawn.
Step 2
The layer draws on its own
MapLibre first draws the whole layer by itself, its features over each other as usual. So a layer never blends with itself, only with the map beneath it.
Step 3
Then it blends in
The finished layer is blended onto everything drawn before it, such as rasters, hillshades, labels and other layers. The formulas are the W3C's, the same as CSS mix-blend-mode, so a mode looks as it does in a browser or a design tool.
What blends with what
Fill, line and raster layers can blend. Every kind of layer can be beneath them, and on a flat map or the globe a blended layer blends onto all of it.
| Layers | Can blend | Can be blended onto | And on 3D terrain |
|---|---|---|---|
| fill, line, raster | Yes | Yes | Yes |
| background, hillshade, color-relief | No | Yes | Yes |
| circle, symbol, heatmap, fill-extrusion | No | Yes | No, and it cuts the blended layer off from the layers beneath it |
On 3D terrain, keep circles, labels, heatmaps and extrusions above the blended layers. MapLibre draws the other layers into flat textures and lays those over the mountains, and a blended layer blends inside its texture. Circles, labels, heatmaps and extrusions go straight onto the screen. When one of them sits between a blended layer and the layers beneath it, the blended layer starts in a new, empty texture, finds nothing to blend with, and draws as a normal layer.
Good to know
- A layer blends as a whole, so its own features never blend with each other, and crossing streets in one layer don't add up. Draw them in two layers to make them, as the city lights do.
- The mode is a constant, so it takes no expressions and no zoom functions.
- Each blended layer is one more pass on the GPU. multiply, overlay, color and luminosity also copy the map beneath first; screen and plus-lighter don't need to. Blend a few layers, not every layer.
- It works with fill-layer-opacity and line-layer-opacity, which fade the blended layer as a whole, on 3D terrain and on the globe. A raster's raster-opacity fades it before it blends.
- MapLibre Native doesn't blend layers yet.
Go further
- fill-layer-blend, line-layer-blend and raster-layer-blend in the MapLibre style spec, with every mode.
- The W3C's blend modes, whose formulas MapLibre follows.
- MapLibre GL JS on this site, with a quick start.