Examples

What you can build on a free map

MapLibre snippets that run on the Waymorphic tiles, on their own and with free public datasets: elevation, earthquakes, aircraft, weather, satellite imagery, live OpenStreetMap, Wikipedia, routing and search. Every example runs live on this page. Copy the code into your page, or open it as a page of its own. No key for any of it.

One style URLEvery snippet starts with new maplibregl.Map and a style URL from the tile domain. The tiles, fonts, sprites and attribution come with it.
Copy what you seeThe code shown is the code running in the preview next to it. Copy the snippet, or the whole page with the script tags, and it works as it is.
Free data, creditedThe datasets are public and need no key. Each one is named on the example, with a link to its terms; keep its attribution on the map.

Start here

The first map, the five styles, the globe, markers, the camera and the controls. Everything on this page builds on these.

01

A map in five lines

One style URL is the whole setup: the tiles, the fonts, the sprites and the attribution all come from it. Change the style name to change the look.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [-0.1276, 51.5074],   // [longitude, latitude]
  zoom: 11,
});
preview
PreviewRuns when it scrolls into view

02

Switch between the five styles

Positron, Bright, Liberty, Dark and Fiord. setStyle loads the new style and keeps the camera where it is.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [4.9041, 52.3676],
  zoom: 12,
});

// A row of buttons over the map, one per style.
const bar = document.createElement('div');
bar.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1;display:flex;gap:6px';
for (const name of ['positron', 'bright', 'liberty', 'dark', 'fiord']) {
  const b = document.createElement('button');
  b.textContent = name;
  b.style.cssText = 'padding:6px 12px;border:0;border-radius:999px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer';
  b.onclick = () => map.setStyle('https://tiles.waymorphic.com/styles/' + name);
  bar.append(b);
}
map.getContainer().append(bar);
preview
PreviewRuns when it scrolls into view

03

The globe

MapLibre 5 draws the map on a sphere. The projection is part of the style, so set it once the style has loaded, and it stays through zooms. At high zoom the globe becomes a flat map on its own.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/fiord',
  center: [20, 20],
  zoom: 1.6,
});

map.on('style.load', () => {
  map.setProjection({ type: 'globe' });
});

// A slow turn until the visitor takes hold of the map.
let spinning = true;
function spin() {
  if (!spinning) return;
  map.easeTo({ center: [map.getCenter().lng + 20, 20], duration: 4000, easing: t => t });
}
map.on('moveend', spin);
map.on('mousedown', () => { spinning = false; });
map.on('touchstart', () => { spinning = false; });
map.on('load', spin);
preview
PreviewRuns when it scrolls into view

04

Markers and popups

A Marker is an HTML element pinned to a coordinate, so it can be any colour or any element. A Popup opens on click, and can hold any HTML.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/bright',
  center: [2.322, 48.859],   // between the three, so all of them are in view
  zoom: 12.5,
});

const places = [
  { name: 'Louvre', lngLat: [2.3364, 48.8606], color: '#e11d48' },
  { name: 'Eiffel Tower', lngLat: [2.2945, 48.8584], color: '#2563eb' },
  { name: 'Notre-Dame', lngLat: [2.3499, 48.8530], color: '#16a34a' },
];

for (const p of places) {
  new maplibregl.Marker({ color: p.color })
    .setLngLat(p.lngLat)
    .setPopup(new maplibregl.Popup({ offset: 30 }).setHTML('<b>' + p.name + '</b><br>' + p.lngLat.join(', ')))
    .addTo(map);
}

// A marker made from your own element: a pulsing dot.
const el = document.createElement('div');
el.style.cssText = 'width:18px;height:18px;border-radius:50%;background:#f59e0b;border:3px solid #fff;box-shadow:0 0 0 6px rgba(245,158,11,.35)';
new maplibregl.Marker({ element: el }).setLngLat([2.3200, 48.8656]).addTo(map);
preview
PreviewRuns when it scrolls into view

05

Fly the camera

flyTo arcs from one place to the next like a flight; easeTo moves in a straight line. Pitch and bearing turn a flat map into a view from the air.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/liberty',
  center: [-74.006, 40.7128],
  zoom: 12,
});

const stops = [
  { name: 'Manhattan', center: [-73.9857, 40.7484], zoom: 14.5, pitch: 60, bearing: 30 },
  { name: 'Venice', center: [12.3155, 45.4408], zoom: 14, pitch: 45, bearing: -20 },
  { name: 'Cape Town', center: [18.4241, -33.9249], zoom: 12, pitch: 50, bearing: 150 },
  { name: 'Sydney', center: [151.2153, -33.8568], zoom: 14, pitch: 55, bearing: 60 },
];

let i = 0;
function next() {
  const s = stops[i++ % stops.length];
  map.flyTo({ ...s, duration: 6000, essential: true });   // essential: runs even with reduced motion
}
map.on('load', next);
map.on('moveend', () => setTimeout(next, 1500));
preview
PreviewRuns when it scrolls into view

06

Controls and a shareable URL

Zoom and compass, a scale bar, fullscreen and a locate button are one line each. hash: true keeps the view in the URL, so a link opens where you left it.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [13.405, 52.52],
  zoom: 12,
  hash: true,             // #zoom/lat/lng/bearing/pitch in the URL
});

map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right');
map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-left');
map.addControl(new maplibregl.FullscreenControl(), 'top-right');
map.addControl(new maplibregl.GeolocateControl({
  positionOptions: { enableHighAccuracy: true },
  trackUserLocation: true,
}), 'top-right');
preview
PreviewRuns when it scrolls into view

07

Keep the map in one area

maxBounds stops the visitor from leaving an area, and minZoom stops them from zooming out of it. fitBounds frames the area to start.

Waymorphic tiles
map.js
// The Isle of Wight: [west, south, east, north]
const bounds = [-1.60, 50.56, -1.06, 50.78];

const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/bright',
  bounds,
  fitBoundsOptions: { padding: 20 },
  maxBounds: [-1.8, 50.45, -0.85, 50.9],   // a little wider than the island
  minZoom: 9,
  maxZoom: 16,
});

map.addControl(new maplibregl.NavigationControl());

// Draw the box, so the limit is visible.
map.on('load', () => {
  map.addSource('box', { type: 'geojson', data: {
    type: 'Feature', geometry: { type: 'Polygon', coordinates: [[
      [bounds[0], bounds[1]], [bounds[2], bounds[1]], [bounds[2], bounds[3]], [bounds[0], bounds[3]], [bounds[0], bounds[1]],
    ]] } } });
  map.addLayer({ id: 'box', type: 'line', source: 'box', paint: { 'line-color': '#e11d48', 'line-width': 2, 'line-dasharray': [2, 2] } });
});
preview
PreviewRuns when it scrolls into view

08

Save the map as a PNG

The map is a canvas. With preserveDrawingBuffer on, toDataURL gives the picture; a hidden link downloads it. The attribution is not part of the canvas, so add it to the picture or under it.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/liberty',
  center: [-122.4194, 37.7749],
  zoom: 12,
  canvasContextAttributes: { preserveDrawingBuffer: true },   // needed for toDataURL
});

const btn = document.createElement('button');
btn.textContent = 'Save PNG';
btn.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1;padding:6px 12px;border:0;border-radius:999px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer';
btn.onclick = () => {
  const a = document.createElement('a');
  a.href = map.getCanvas().toDataURL('image/png');
  a.download = 'map.png';
  a.click();
};
map.getContainer().append(btn);
preview
PreviewRuns when it scrolls into view

Style the tiles your way

The tiles carry the OpenMapTiles layers: water, landuse, transportation, building, place, poi, and the rest. A style is only a list of rules over them, so any rule can be changed, and new ones added, in the browser.

09

Recolour a style

Every layer in the style can be repainted after it loads. Find the layers by the tile layer they draw (source-layer), so the same code works on all five styles.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [-73.98, 40.75],
  zoom: 12.5,
});

map.on('load', () => {
  const layers = map.getStyle().layers;
  // Paint by what a layer draws, not by its name: the names differ between styles.
  const of = (sourceLayer, type) => layers.filter(l => l['source-layer'] === sourceLayer && l.type === type).map(l => l.id);

  for (const id of of('water', 'fill')) map.setPaintProperty(id, 'fill-color', '#0f2f5f');
  for (const id of of('waterway', 'line')) map.setPaintProperty(id, 'line-color', '#0f2f5f');
  for (const id of of('building', 'fill')) map.setPaintProperty(id, 'fill-color', '#e8dcc6');
  for (const id of of('park', 'fill')) map.setPaintProperty(id, 'fill-color', '#b7d8a8');
  for (const id of layers.filter(l => l.type === 'background').map(l => l.id)) map.setPaintProperty(id, 'background-color', '#f7f2e8');
  for (const id of of('transportation', 'line')) map.setPaintProperty(id, 'line-color', '#ffffff');
});
preview
PreviewRuns when it scrolls into view

10

Hide what you do not need

A basemap under your own data wants fewer labels and no points of interest. Hide layers by the tile layer they draw; keep the place names.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/bright',
  center: [-3.7038, 40.4168],
  zoom: 13,
});

map.on('load', () => {
  // Tile layers to drop: shops and amenities, house numbers, street names and shields.
  const drop = new Set(['poi', 'housenumber', 'transportation_name', 'aerodrome_label', 'water_name']);
  for (const l of map.getStyle().layers) {
    if (drop.has(l['source-layer'])) map.setLayoutProperty(l.id, 'visibility', 'none');
  }
});
preview
PreviewRuns when it scrolls into view

11

Labels in another language

The tiles carry names in many languages (name:en, name:de, name:ja, name:ar and more). Point every label layer at one of them, and fall back to the local name where there is no translation.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [10, 48],
  zoom: 4.5,
});

function setLanguage(lang) {
  for (const l of map.getStyle().layers) {
    if (l.type !== 'symbol' || !l['source-layer'] || !l.layout || !l.layout['text-field']) continue;
    map.setLayoutProperty(l.id, 'text-field', [
      'coalesce', ['get', 'name:' + lang], ['get', 'name:latin'], ['get', 'name'],
    ]);
  }
}

const bar = document.createElement('div');
bar.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1;display:flex;gap:6px';
for (const [lang, label] of [['en', 'English'], ['de', 'Deutsch'], ['fr', 'Français'], ['ja', '日本語'], ['ar', 'العربية']]) {
  const b = document.createElement('button');
  b.textContent = label;
  b.style.cssText = 'padding:6px 12px;border:0;border-radius:999px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer';
  b.onclick = () => setLanguage(lang);
  bar.append(b);
}
map.getContainer().append(bar);

map.on('load', () => setLanguage('en'));
preview
PreviewRuns when it scrolls into view

12

Roads coloured by class

The transportation layer carries the road class of every line. One layer with a match expression colours them all, on top of the style.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/dark',
  center: [-87.65, 41.88],
  zoom: 11,
});

map.on('load', () => {
  map.addLayer({
    id: 'roads-by-class',
    type: 'line',
    source: 'openmaptiles',            // the vector source every style uses
    'source-layer': 'transportation',
    filter: ['!=', ['get', 'brunnel'], 'tunnel'],
    paint: {
      'line-color': ['match', ['get', 'class'],
        'motorway', '#f43f5e',
        'trunk', '#fb923c',
        'primary', '#facc15',
        'secondary', '#a3e635',
        'tertiary', '#34d399',
        'rail', '#a78bfa',
        '#475569'],
      'line-width': ['match', ['get', 'class'], 'motorway', 3, 'trunk', 2.5, 'primary', 2, 1],
      'line-opacity': 0.9,
    },
  });
});
preview
PreviewRuns when it scrolls into view

13

A transit map from the tiles

Railways and stations are in the tiles at every zoom. Draw them alone over a quiet style and the result is a transit map with no data of your own.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [-0.1, 51.51],
  zoom: 12,
});

map.on('load', () => {
  map.addLayer({
    id: 'rail-lines',
    type: 'line',
    source: 'openmaptiles',
    'source-layer': 'transportation',
    filter: ['in', ['get', 'class'], ['literal', ['rail', 'transit']]],
    paint: {
      'line-color': ['match', ['get', 'subclass'], 'subway', '#dc2626', 'light_rail', '#16a34a', 'tram', '#0284c7', '#1f2937'],
      'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1, 15, 4],
    },
  });
  map.addLayer({
    id: 'stations',
    type: 'circle',
    source: 'openmaptiles',
    'source-layer': 'poi',
    filter: ['==', ['get', 'class'], 'railway'],
    paint: { 'circle-radius': 5, 'circle-color': '#fff', 'circle-stroke-width': 2, 'circle-stroke-color': '#1f2937' },
  });
  map.addLayer({
    id: 'station-names',
    type: 'symbol',
    source: 'openmaptiles',
    'source-layer': 'poi',
    filter: ['==', ['get', 'class'], 'railway'],
    minzoom: 13,
    layout: { 'text-field': ['get', 'name'], 'text-font': ['Noto Sans Regular'], 'text-size': 11, 'text-offset': [0, 1], 'text-anchor': 'top' },
    paint: { 'text-halo-color': '#fff', 'text-halo-width': 1.5 },
  });
});
preview
PreviewRuns when it scrolls into view

14

Green space and water

Parks, woods, grass and wetlands come in the park, landcover and landuse layers. Colour them by class and the map becomes a green-space map.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [-0.16, 51.51],
  zoom: 12,
});

map.on('load', () => {
  const firstLabel = map.getStyle().layers.find(l => l.type === 'symbol').id;   // keep labels on top
  map.addLayer({
    id: 'landcover-green',
    type: 'fill',
    source: 'openmaptiles',
    'source-layer': 'landcover',
    paint: {
      'fill-color': ['match', ['get', 'class'], 'wood', '#2f855a', 'grass', '#9ae6b4', 'wetland', '#81e6d9', 'farmland', '#faf089', 'rgba(0,0,0,0)'],
      'fill-opacity': 0.6,
    },
  }, firstLabel);
  map.addLayer({
    id: 'parks',
    type: 'fill',
    source: 'openmaptiles',
    'source-layer': 'park',
    paint: { 'fill-color': '#48bb78', 'fill-opacity': 0.5 },
  }, firstLabel);
  map.addLayer({
    id: 'park-names',
    type: 'symbol',
    source: 'openmaptiles',
    'source-layer': 'park',
    filter: ['==', ['geometry-type'], 'Point'],
    layout: { 'text-field': ['get', 'name'], 'text-font': ['Noto Sans Italic'], 'text-size': 12 },
    paint: { 'text-color': '#22543d', 'text-halo-color': '#fff', 'text-halo-width': 1.5 },
  });
});
preview
PreviewRuns when it scrolls into view

15

Mountain peaks with their heights

The mountain_peak layer has a point, a name and an elevation for every summit. A symbol layer labels them; rank keeps the busy areas readable.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/liberty',
  center: [7.85, 46.0],
  zoom: 10,
});

map.on('load', () => {
  map.addLayer({
    id: 'peaks',
    type: 'symbol',
    source: 'openmaptiles',
    'source-layer': 'mountain_peak',
    filter: ['all', ['==', ['get', 'class'], 'peak'], ['<=', ['get', 'rank'], 3]],
    layout: {
      'text-field': ['concat', ['get', 'name'], '\n', ['get', 'ele'], ' m'],
      'text-font': ['Noto Sans Bold'],
      'text-size': ['interpolate', ['linear'], ['zoom'], 8, 10, 12, 14],
      'text-offset': [0, 0.6],
      'text-anchor': 'top',
    },
    paint: { 'text-color': '#7c2d12', 'text-halo-color': '#fff', 'text-halo-width': 1.5 },
  });
  map.addLayer({
    id: 'peak-dots',
    type: 'circle',
    source: 'openmaptiles',
    'source-layer': 'mountain_peak',
    filter: ['==', ['get', 'class'], 'peak'],
    paint: { 'circle-radius': 3, 'circle-color': '#7c2d12' },
  });
});
preview
PreviewRuns when it scrolls into view

16

Airports and runways

Runways, taxiways and aprons are in the aeroway layer; the airport name and its IATA code in aerodrome_label. Here they are drawn bold over a dark base.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/dark',
  center: [4.7683, 52.3105],
  zoom: 12.5,
});

map.on('load', () => {
  map.addLayer({
    id: 'aprons',
    type: 'fill',
    source: 'openmaptiles',
    'source-layer': 'aeroway',
    filter: ['==', ['get', 'class'], 'apron'],
    paint: { 'fill-color': '#334155' },
  });
  map.addLayer({
    id: 'runways',
    type: 'line',
    source: 'openmaptiles',
    'source-layer': 'aeroway',
    filter: ['in', ['get', 'class'], ['literal', ['runway', 'taxiway']]],
    paint: {
      'line-color': ['match', ['get', 'class'], 'runway', '#facc15', '#64748b'],
      'line-width': ['match', ['get', 'class'], 'runway', ['interpolate', ['linear'], ['zoom'], 11, 2, 15, 16], 2],
    },
  });
  map.addLayer({
    id: 'airport-names',
    type: 'symbol',
    source: 'openmaptiles',
    'source-layer': 'aerodrome_label',
    layout: {
      'text-field': ['concat', ['get', 'name'], ' (', ['get', 'iata'], ')'],
      'text-font': ['Noto Sans Bold'],
      'text-size': 14,
    },
    paint: { 'text-color': '#facc15', 'text-halo-color': '#000', 'text-halo-width': 1.5 },
  });
});
preview
PreviewRuns when it scrolls into view

17

A style from scratch

A style is JSON: a source pointing at the TileJSON, a glyph URL for the labels, and layers. This one draws only water, roads and place names, in a blueprint look, from the same tiles.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  center: [-122.42, 37.77],
  zoom: 12,
  style: {
    version: 8,
    glyphs: 'https://tiles.waymorphic.com/fonts/{fontstack}/{range}.pbf',
    sources: {
      openmaptiles: { type: 'vector', url: 'https://tiles.waymorphic.com/planet' },
    },
    layers: [
      { id: 'background', type: 'background', paint: { 'background-color': '#0b3d91' } },
      { id: 'water', type: 'fill', source: 'openmaptiles', 'source-layer': 'water', paint: { 'fill-color': '#062a66' } },
      { id: 'roads', type: 'line', source: 'openmaptiles', 'source-layer': 'transportation',
        paint: { 'line-color': '#ffffff', 'line-opacity': 0.8,
          'line-width': ['match', ['get', 'class'], 'motorway', 2.5, 'trunk', 2, 'primary', 1.5, 0.6] } },
      { id: 'buildings', type: 'line', source: 'openmaptiles', 'source-layer': 'building', minzoom: 14,
        paint: { 'line-color': '#93c5fd', 'line-opacity': 0.6 } },
      { id: 'places', type: 'symbol', source: 'openmaptiles', 'source-layer': 'place',
        filter: ['in', ['get', 'class'], ['literal', ['city', 'town', 'suburb', 'neighbourhood']]],
        layout: { 'text-field': ['get', 'name'], 'text-font': ['Noto Sans Bold'], 'text-size': 13, 'text-transform': 'uppercase', 'text-letter-spacing': 0.1 },
        paint: { 'text-color': '#ffffff', 'text-halo-color': '#0b3d91', 'text-halo-width': 1 } },
    ],
  },
});
preview
PreviewRuns when it scrolls into view

18

Click anything to see its data

queryRenderedFeatures returns what is under the pointer: the tile layer, the class, the name and every other attribute. This is how to learn what the tiles carry.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/bright',
  center: [-0.1276, 51.5074],
  zoom: 15,
});

map.on('click', e => {
  const f = map.queryRenderedFeatures(e.point)[0];
  if (!f) return;
  const rows = Object.entries(f.properties).map(([k, v]) => '<tr><td style="color:#888;padding-right:8px">' + k + '</td><td>' + v + '</td></tr>').join('');
  new maplibregl.Popup({ maxWidth: '320px' })
    .setLngLat(e.lngLat)
    .setHTML('<b>' + f.sourceLayer + '</b> · layer <code>' + f.layer.id + '</code><table style="font-size:12px;margin-top:6px">' + rows + '</table>')
    .addTo(map);
});

map.on('mousemove', e => {
  map.getCanvas().style.cursor = map.queryRenderedFeatures(e.point).length ? 'pointer' : '';
});
preview
PreviewRuns when it scrolls into view

19

Highlight the street under the pointer

A highlight layer with a filter that matches nothing, then set the filter to the name of the road under the pointer. The whole street lights up, across every tile it crosses.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [-3.19, 55.95],
  zoom: 14,
});

map.on('load', () => {
  map.addLayer({
    id: 'street-hover',
    type: 'line',
    source: 'openmaptiles',
    'source-layer': 'transportation',
    filter: ['==', ['get', 'name'], ''],          // nothing, to start
    paint: { 'line-color': '#e11d48', 'line-width': 6, 'line-opacity': 0.6 },
  });
});

const roads = () => map.getStyle().layers.filter(l => l['source-layer'] === 'transportation' && l.type === 'line').map(l => l.id);
let current = '';
map.on('mousemove', e => {
  const f = map.queryRenderedFeatures(e.point, { layers: roads() }).find(f => f.properties.name);
  const name = f ? f.properties.name : '';
  if (name === current) return;
  current = name;
  map.setFilter('street-hover', ['==', ['get', 'name'], name]);
  map.getCanvas().style.cursor = name ? 'pointer' : '';
  map.getCanvas().title = name;
});
preview
PreviewRuns when it scrolls into view

3D, terrain and sky

Building heights come in the tiles. Elevation comes from Mapterhorn, a free raster-dem tileset for the whole planet. Together they give 3D buildings, terrain, hillshade, contours and a sky.

20

3D buildings

Every building in the tiles has render_height and render_min_height. A fill-extrusion layer raises them from zoom 15; this is what the map browser does.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [-73.985, 40.748],
  zoom: 15.5,
  pitch: 60,
  bearing: -20,
});

map.on('load', () => {
  const layers = map.getStyle().layers;
  // Above the roads, below the labels: after the last line layer, before the next symbol layer.
  const lastLine = layers.map(l => l.type).lastIndexOf('line');
  const before = layers.find((l, i) => i > lastLine && l.type === 'symbol');

  map.addLayer({
    id: 'buildings-3d',
    type: 'fill-extrusion',
    source: 'openmaptiles',
    'source-layer': 'building',
    minzoom: 15,
    paint: {
      'fill-extrusion-color': '#d6d3d1',
      'fill-extrusion-opacity': 0.85,
      // Rise over one zoom level, so the buildings do not pop in.
      'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 16, ['coalesce', ['get', 'render_height'], 5]],
      'fill-extrusion-base': ['interpolate', ['linear'], ['zoom'], 15, 0, 16, ['coalesce', ['get', 'render_min_height'], 0]],
    },
  }, before && before.id);
});
preview
PreviewRuns when it scrolls into view

21

Buildings coloured by height

The same extrusion, with the colour driven by the height: an interpolate expression from low and pale to tall and red.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/dark',
  center: [-0.02, 51.505],
  zoom: 15,
  pitch: 55,
  bearing: 30,
});

map.on('load', () => {
  map.addLayer({
    id: 'buildings-by-height',
    type: 'fill-extrusion',
    source: 'openmaptiles',
    'source-layer': 'building',
    minzoom: 14,
    paint: {
      'fill-extrusion-color': ['interpolate', ['linear'], ['coalesce', ['get', 'render_height'], 0],
        0, '#1e293b', 20, '#3b82f6', 60, '#facc15', 120, '#f97316', 200, '#ef4444'],
      'fill-extrusion-height': ['coalesce', ['get', 'render_height'], 5],
      'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0],
      'fill-extrusion-opacity': 0.9,
    },
  });
});
preview
PreviewRuns when it scrolls into view

22

3D terrain

Mapterhorn serves elevation for the whole planet as Terrarium-encoded raster-dem tiles, free and without a key. One source and setTerrain, and the map has relief.

Waymorphic tilesMapterhorn
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/liberty',
  center: [7.98, 46.55],
  zoom: 12,
  pitch: 65,
  bearing: 160,
  maxPitch: 85,
});

map.on('load', () => {
  map.addSource('dem', {
    type: 'raster-dem',
    url: 'https://tiles.mapterhorn.com/tilejson.json',   // attribution comes with the TileJSON
    encoding: 'terrarium',
  });
  map.setTerrain({ source: 'dem', exaggeration: 1.3 });
  map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }));
  map.addControl(new maplibregl.TerrainControl({ source: 'dem', exaggeration: 1.3 }));
});
preview
PreviewRuns when it scrolls into view

23

Hillshade under the roads

A hillshade layer from the same elevation tiles gives a flat map relief. Put it under the roads and labels, and keep the map flat, so it works on the globe too.

Waymorphic tilesMapterhorn
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [-121.7, 46.85],
  zoom: 10,
});

map.on('load', () => {
  map.addSource('dem', { type: 'raster-dem', url: 'https://tiles.mapterhorn.com/tilejson.json', encoding: 'terrarium' });

  // Under the first line layer (the roads), so the streets and labels stay crisp.
  const firstLine = map.getStyle().layers.find(l => l.type === 'line');
  map.addLayer({
    id: 'hillshade',
    type: 'hillshade',
    source: 'dem',
    paint: {
      'hillshade-exaggeration': 0.4,
      'hillshade-shadow-color': '#473b24',
      'hillshade-highlight-color': '#ffffff',
      'hillshade-illumination-direction': 335,
    },
  }, firstLine && firstLine.id);
});
preview
PreviewRuns when it scrolls into view

24

Sky and fog

With terrain and a high pitch the horizon shows. setSky paints the sky and blends fog into the distance; on the globe the same setting draws the atmosphere.

Waymorphic tilesMapterhorn
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/fiord',
  center: [8.55, 46.72],
  zoom: 11.5,
  pitch: 80,
  bearing: 40,
  maxPitch: 85,
});

map.on('load', () => {
  map.addSource('dem', { type: 'raster-dem', url: 'https://tiles.mapterhorn.com/tilejson.json', encoding: 'terrarium' });
  map.setTerrain({ source: 'dem', exaggeration: 1.2 });
  map.setSky({
    'sky-color': '#0b1a33',
    'horizon-color': '#c9a27e',
    'fog-color': '#2b3a49',
    'fog-ground-blend': 0.6,
    'horizon-fog-blend': 0.7,
    'sky-horizon-blend': 0.6,
    'atmosphere-blend': ['interpolate', ['linear'], ['zoom'], 0, 1, 10, 1, 12, 0],
  });
});
preview
PreviewRuns when it scrolls into view

25

Contour lines from the elevation tiles

maplibre-contour turns raster-dem tiles into contour lines in the browser, in a worker, at whatever interval fits the zoom. The tile URL and maximum zoom are read from Mapterhorn's TileJSON.

map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [-3.65, 56.97],
  zoom: 11.5,
});

map.on('load', async () => {
  const tj = await (await fetch('https://tiles.mapterhorn.com/tilejson.json')).json();
  const dem = new mlcontour.DemSource({ url: tj.tiles[0], encoding: 'terrarium', maxzoom: tj.maxzoom, worker: true });
  dem.setupMaplibre(maplibregl);

  map.addSource('contours', {
    type: 'vector',
    tiles: [dem.contourProtocolUrl({
      thresholds: { 10: [100, 500], 12: [50, 200], 14: [20, 100], 15: [10, 50] },   // zoom: [minor, major] metres
      elevationKey: 'ele', levelKey: 'level', contourLayer: 'contours',
    })],
    maxzoom: 15,
    attribution: tj.attribution,
  });
  const firstSymbol = map.getStyle().layers.find(l => l.type === 'symbol').id;
  map.addLayer({
    id: 'contour-lines', type: 'line', source: 'contours', 'source-layer': 'contours',
    paint: { 'line-color': '#7c5a2b', 'line-opacity': 0.6, 'line-width': ['match', ['get', 'level'], 1, 1.2, 0.5] },
  }, firstSymbol);
  map.addLayer({
    id: 'contour-labels', type: 'symbol', source: 'contours', 'source-layer': 'contours',
    filter: ['==', ['get', 'level'], 1],
    layout: { 'symbol-placement': 'line', 'text-field': ['concat', ['get', 'ele'], ' m'], 'text-font': ['Noto Sans Regular'], 'text-size': 10 },
    paint: { 'text-color': '#7c5a2b', 'text-halo-color': '#fff', 'text-halo-width': 1 },
  });
});
preview
PreviewRuns when it scrolls into view

26

The sun moving over the hills

The hillshade has a light direction. Animate it through the day and the shadows swing round the mountains.

Waymorphic tilesMapterhorn
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/dark',
  center: [86.92, 27.98],
  zoom: 10,
});

map.on('load', () => {
  map.addSource('dem', { type: 'raster-dem', url: 'https://tiles.mapterhorn.com/tilejson.json', encoding: 'terrarium' });
  const firstLine = map.getStyle().layers.find(l => l.type === 'line');
  map.addLayer({
    id: 'hillshade', type: 'hillshade', source: 'dem',
    paint: { 'hillshade-exaggeration': 0.7, 'hillshade-shadow-color': '#000', 'hillshade-highlight-color': '#9ca3af', 'hillshade-illumination-anchor': 'map' },
  }, firstLine && firstLine.id);

  let hour = 6;
  setInterval(() => {
    hour = (hour + 0.05) % 24;
    // East at 6:00, south at 12:00, west at 18:00: the sun's azimuth, roughly.
    map.setPaintProperty('hillshade', 'hillshade-illumination-direction', (hour * 15 + 270) % 360);
  }, 40);
});
preview
PreviewRuns when it scrolls into view

Your own data

GeoJSON, CSV, a route, a drawing: MapLibre draws them on top of the basemap as circles, lines, fills, heatmaps and clusters.

27

Points from GeoJSON

A GeoJSON source and a circle layer draw any number of points. Colour and size come from the properties; a click opens a popup.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [-0.131, 51.512],
  zoom: 11.7,
});

const stations = { type: 'FeatureCollection', features: [
  { type: 'Feature', geometry: { type: 'Point', coordinates: [-0.1246, 51.5308] }, properties: { name: 'King\'s Cross', lines: 6 } },
  { type: 'Feature', geometry: { type: 'Point', coordinates: [-0.1440, 51.5150] }, properties: { name: 'Oxford Circus', lines: 3 } },
  { type: 'Feature', geometry: { type: 'Point', coordinates: [-0.0865, 51.5045] }, properties: { name: 'London Bridge', lines: 2 } },
  { type: 'Feature', geometry: { type: 'Point', coordinates: [-0.1257, 51.5033] }, properties: { name: 'Westminster', lines: 3 } },
  { type: 'Feature', geometry: { type: 'Point', coordinates: [-0.1759, 51.4924] }, properties: { name: 'Earl\'s Court', lines: 2 } },
] };

map.on('load', () => {
  map.addSource('stations', { type: 'geojson', data: stations });
  map.addLayer({
    id: 'stations',
    type: 'circle',
    source: 'stations',
    paint: {
      'circle-radius': ['*', ['get', 'lines'], 3],
      'circle-color': ['interpolate', ['linear'], ['get', 'lines'], 2, '#60a5fa', 6, '#dc2626'],
      'circle-stroke-color': '#fff',
      'circle-stroke-width': 2,
    },
  });
  map.on('click', 'stations', e => {
    const p = e.features[0].properties;
    new maplibregl.Popup().setLngLat(e.lngLat).setHTML('<b>' + p.name + '</b><br>' + p.lines + ' lines').addTo(map);
  });
  map.on('mouseenter', 'stations', () => { map.getCanvas().style.cursor = 'pointer'; });
  map.on('mouseleave', 'stations', () => { map.getCanvas().style.cursor = ''; });
});
preview
PreviewRuns when it scrolls into view

28

Cluster thousands of points

cluster: true on the source groups nearby points at each zoom, with a count. Here it is a month of earthquakes from the USGS; a click on a cluster zooms in until it opens.

Waymorphic tilesUSGS earthquake feed
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/dark',
  center: [-120, 38],
  zoom: 4.5,
});

map.on('load', () => {
  map.addSource('quakes', {
    type: 'geojson',
    data: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojson',
    cluster: true,
    clusterRadius: 50,
    clusterMaxZoom: 12,
  });
  map.addLayer({
    id: 'clusters', type: 'circle', source: 'quakes', filter: ['has', 'point_count'],
    paint: {
      'circle-color': ['step', ['get', 'point_count'], '#60a5fa', 100, '#f59e0b', 750, '#ef4444'],
      'circle-radius': ['step', ['get', 'point_count'], 16, 100, 24, 750, 32],
      'circle-opacity': 0.85,
    },
  });
  map.addLayer({
    id: 'cluster-count', type: 'symbol', source: 'quakes', filter: ['has', 'point_count'],
    layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-font': ['Noto Sans Bold'], 'text-size': 12 },
    paint: { 'text-color': '#fff' },
  });
  map.addLayer({
    id: 'points', type: 'circle', source: 'quakes', filter: ['!', ['has', 'point_count']],
    paint: { 'circle-color': '#f472b6', 'circle-radius': 5, 'circle-stroke-width': 1, 'circle-stroke-color': '#fff' },
  });

  map.on('click', 'clusters', async e => {
    const f = e.features[0];
    const zoom = await map.getSource('quakes').getClusterExpansionZoom(f.properties.cluster_id);
    map.easeTo({ center: f.geometry.coordinates, zoom });
  });
  map.on('click', 'points', e => {
    const p = e.features[0].properties;
    new maplibregl.Popup().setLngLat(e.lngLat).setHTML('<b>M ' + p.mag + '</b> ' + p.place + '<br>' + new Date(p.time).toUTCString()).addTo(map);
  });
  map.on('mouseenter', 'clusters', () => { map.getCanvas().style.cursor = 'pointer'; });
  map.on('mouseleave', 'clusters', () => { map.getCanvas().style.cursor = ''; });
});
preview
PreviewRuns when it scrolls into view

29

A heatmap

A heatmap layer sums the points into a density surface, weighted here by magnitude. It fades into single circles as the zoom goes in.

Waymorphic tilesUSGS earthquake feed
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/dark',
  center: [140, 36],
  zoom: 4,
});

map.on('load', () => {
  map.addSource('quakes', { type: 'geojson', data: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojson' });
  map.addLayer({
    id: 'heat', type: 'heatmap', source: 'quakes', maxzoom: 9,
    paint: {
      'heatmap-weight': ['interpolate', ['linear'], ['get', 'mag'], 0, 0, 6, 1],
      'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 9, 3],
      'heatmap-color': ['interpolate', ['linear'], ['heatmap-density'],
        0, 'rgba(33,102,172,0)', 0.2, '#2166ac', 0.4, '#67a9cf', 0.6, '#fddbc7', 0.8, '#ef8a62', 1, '#b2182b'],
      'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 9, 20],
      'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 1, 9, 0],
    },
  });
  map.addLayer({
    id: 'points', type: 'circle', source: 'quakes', minzoom: 7,
    paint: {
      'circle-radius': ['interpolate', ['linear'], ['get', 'mag'], 1, 2, 6, 20],
      'circle-color': '#ef8a62', 'circle-stroke-color': '#fff', 'circle-stroke-width': 1,
      'circle-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0, 8, 1],
    },
  });
});
preview
PreviewRuns when it scrolls into view

30

A route with a gradient

A line layer draws a track. With lineMetrics on the source, line-gradient colours it along its length, from start to finish.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [-0.058, 51.503],   // the middle of the track
  zoom: 11.6,
});

// A few points along the Thames, as a walk might record them.
const track = { type: 'Feature', geometry: { type: 'LineString', coordinates: [
  [-0.1246, 51.5007], [-0.1150, 51.5045], [-0.1040, 51.5081], [-0.0925, 51.5077], [-0.0784, 51.5058],
  [-0.0650, 51.5046], [-0.0520, 51.5030], [-0.0400, 51.5023], [-0.0250, 51.5035], [-0.0100, 51.5050],
  [-0.0020, 51.5015], [0.0080, 51.4980],
] } };

map.on('load', () => {
  map.addSource('track', { type: 'geojson', data: track, lineMetrics: true });
  map.addLayer({
    id: 'track-casing', type: 'line', source: 'track',
    layout: { 'line-cap': 'round', 'line-join': 'round' },
    paint: { 'line-color': '#fff', 'line-width': 10 },
  });
  map.addLayer({
    id: 'track', type: 'line', source: 'track',
    layout: { 'line-cap': 'round', 'line-join': 'round' },
    paint: {
      'line-width': 6,
      'line-gradient': ['interpolate', ['linear'], ['line-progress'], 0, '#22c55e', 0.5, '#eab308', 1, '#ef4444'],
    },
  });
  new maplibregl.Marker({ color: '#22c55e' }).setLngLat(track.geometry.coordinates[0]).addTo(map);
  new maplibregl.Marker({ color: '#ef4444' }).setLngLat(track.geometry.coordinates.at(-1)).addTo(map);
});
preview
PreviewRuns when it scrolls into view

31

Move a point along a route

Update a GeoJSON source every frame and the point moves. The position comes from walking along the line by distance, so the speed is even.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/bright',
  center: [12.477, 41.896],   // the middle of the route
  zoom: 12.8,
});

const route = [[12.4534, 41.9029], [12.4663, 41.9027], [12.4768, 41.8986], [12.4823, 41.8955], [12.4922, 41.8902], [12.4964, 41.8902], [12.5000, 41.8880]];

// Lengths of each leg, in degrees (good enough over a few kilometres).
const legs = route.slice(1).map((p, i) => Math.hypot(p[0] - route[i][0], p[1] - route[i][1]));
const total = legs.reduce((a, b) => a + b, 0);
function at(t) {                           // t from 0 to 1 -> [lng, lat] along the route
  let d = t * total;
  for (let i = 0; i < legs.length; i++) {
    if (d <= legs[i]) { const k = d / legs[i]; return [route[i][0] + (route[i + 1][0] - route[i][0]) * k, route[i][1] + (route[i + 1][1] - route[i][1]) * k]; }
    d -= legs[i];
  }
  return route.at(-1);
}

map.on('load', () => {
  map.addSource('route', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: route } } });
  map.addLayer({ id: 'route', type: 'line', source: 'route', paint: { 'line-color': '#2563eb', 'line-width': 4, 'line-opacity': 0.6 } });
  map.addSource('dot', { type: 'geojson', data: { type: 'Point', coordinates: route[0] } });
  map.addLayer({ id: 'dot', type: 'circle', source: 'dot', paint: { 'circle-radius': 8, 'circle-color': '#dc2626', 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 } });

  const start = performance.now();
  function frame(now) {
    const t = ((now - start) / 12000) % 1;      // one trip every 12 seconds
    map.getSource('dot').setData({ type: 'Point', coordinates: at(t) });
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
});
preview
PreviewRuns when it scrolls into view

32

Countries coloured by population

Natural Earth publishes country outlines as GeoJSON. A fill layer colours each by its POP_EST property, and a click names it. The boundaries in the basemap are hidden under the fills.

Waymorphic tilesNatural Earth
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [15, 25],
  zoom: 1.5,
});

map.on('load', () => {
  map.addSource('countries', {
    type: 'geojson',
    data: 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson',
    attribution: 'Natural Earth',
  });
  const firstSymbol = map.getStyle().layers.find(l => l.type === 'symbol').id;
  map.addLayer({
    id: 'population', type: 'fill', source: 'countries',
    paint: {
      'fill-color': ['interpolate', ['linear'], ['get', 'POP_EST'],
        1e6, '#eff6ff', 1e7, '#bfdbfe', 5e7, '#60a5fa', 2e8, '#2563eb', 1e9, '#1e3a8a'],
      'fill-opacity': 0.8,
    },
  }, firstSymbol);
  map.addLayer({ id: 'population-line', type: 'line', source: 'countries', paint: { 'line-color': '#fff', 'line-width': 0.5 } }, firstSymbol);

  map.on('click', 'population', e => {
    const p = e.features[0].properties;
    new maplibregl.Popup().setLngLat(e.lngLat).setHTML('<b>' + p.NAME + '</b><br>' + Number(p.POP_EST).toLocaleString() + ' people').addTo(map);
  });
});
preview
PreviewRuns when it scrolls into view

33

Points from a CSV

A spreadsheet export with a latitude and a longitude column is a few lines from a map: split the text, build GeoJSON, label the points.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [-96, 42],
  zoom: 2.8,
});

const csv = 'name,lat,lng,visitors\n' +
  'Great Smoky Mountains,35.61,-83.49,13.3\n' +
  'Grand Canyon,36.06,-112.14,4.7\n' +
  'Zion,37.30,-113.03,4.6\n' +
  'Yellowstone,44.43,-110.59,4.5\n' +
  'Rocky Mountain,40.34,-105.68,4.1\n' +
  'Yosemite,37.87,-119.54,3.9\n' +
  'Acadia,44.35,-68.21,3.9\n' +
  'Olympic,47.80,-123.60,2.9\n';

// The simplest parser: one row per line, one value per comma. Use a CSV library for quoted fields.
const [head, ...rows] = csv.trim().split('\n').map(r => r.split(','));
const features = rows.map(r => {
  const p = Object.fromEntries(head.map((k, i) => [k, r[i]]));
  return { type: 'Feature', geometry: { type: 'Point', coordinates: [+p.lng, +p.lat] }, properties: { name: p.name, visitors: +p.visitors } };
});

map.on('load', () => {
  map.addSource('parks', { type: 'geojson', data: { type: 'FeatureCollection', features } });
  map.addLayer({
    id: 'parks', type: 'circle', source: 'parks',
    paint: { 'circle-radius': ['*', ['get', 'visitors'], 2.5], 'circle-color': '#16a34a', 'circle-opacity': 0.7, 'circle-stroke-color': '#fff', 'circle-stroke-width': 1.5 },
  });
  map.addLayer({
    id: 'park-labels', type: 'symbol', source: 'parks',
    layout: { 'text-field': ['concat', ['get', 'name'], '\n', ['get', 'visitors'], ' M'], 'text-font': ['Noto Sans Regular'], 'text-size': 11, 'text-offset': [0, 1.2], 'text-anchor': 'top' },
    paint: { 'text-halo-color': '#fff', 'text-halo-width': 1.5 },
  });
});
preview
PreviewRuns when it scrolls into view

34

A draggable marker

A marker with draggable: true fires dragend with its new position. This is the "pick a location" control in a form.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/bright',
  center: [-6.26, 53.35],
  zoom: 13,
});

const out = document.createElement('div');
out.style.cssText = 'position:absolute;bottom:30px;left:10px;z-index:1;padding:8px 12px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.3);font:13px/1.4 system-ui';
map.getContainer().append(out);

const marker = new maplibregl.Marker({ draggable: true, color: '#dc2626' })
  .setLngLat([-6.26, 53.35])
  .addTo(map);

const show = () => {
  const { lng, lat } = marker.getLngLat();
  out.innerHTML = 'Latitude <b>' + lat.toFixed(5) + '</b> · Longitude <b>' + lng.toFixed(5) + '</b><br><small>Drag the pin</small>';
};
marker.on('drag', show);
marker.on('dragend', show);   // save it here
show();
preview
PreviewRuns when it scrolls into view

35

Draw a shape and measure its area

Every click adds a corner; the polygon redraws as you go, and its area is computed on the sphere. No drawing library needed for a simple shape.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [2.29, 48.86],
  zoom: 14,
});

const pts = [];
const out = document.createElement('div');
out.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1;padding:8px 12px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.3);font:13px system-ui';
out.textContent = 'Click to add corners. Double-click to clear.';
map.getContainer().append(out);

// Spherical polygon area, in square metres (the formula in Chamberlain and Duquette, 2007).
function area(ring) {
  const R = 6378137, r = Math.PI / 180;
  let s = 0;
  for (let i = 0; i < ring.length; i++) {
    const [x1, y1] = ring[i], [x2, y2] = ring[(i + 1) % ring.length];
    s += (x2 - x1) * r * (2 + Math.sin(y1 * r) + Math.sin(y2 * r));
  }
  return Math.abs(s * R * R / 2);
}

map.on('load', () => {
  map.addSource('shape', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  map.addLayer({ id: 'shape-fill', type: 'fill', source: 'shape', paint: { 'fill-color': '#2563eb', 'fill-opacity': 0.25 } });
  map.addLayer({ id: 'shape-line', type: 'line', source: 'shape', paint: { 'line-color': '#2563eb', 'line-width': 2 } });
  map.addLayer({ id: 'shape-pts', type: 'circle', source: 'shape', filter: ['==', ['geometry-type'], 'Point'], paint: { 'circle-radius': 5, 'circle-color': '#fff', 'circle-stroke-color': '#2563eb', 'circle-stroke-width': 2 } });
});

function redraw() {
  const features = pts.map(p => ({ type: 'Feature', geometry: { type: 'Point', coordinates: p } }));
  if (pts.length >= 3) features.push({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [[...pts, pts[0]]] } });
  map.getSource('shape').setData({ type: 'FeatureCollection', features });
  const a = pts.length >= 3 ? area(pts) : 0;
  out.textContent = pts.length >= 3 ? (a / 1e4).toFixed(2) + ' ha · ' + (a / 1e6).toFixed(3) + ' km²' : 'Click to add corners. Double-click to clear.';
}
map.doubleClickZoom.disable();
map.on('click', e => { pts.push([e.lngLat.lng, e.lngLat.lat]); redraw(); });
map.on('dblclick', () => { pts.length = 0; redraw(); });
preview
PreviewRuns when it scrolls into view

Live data from free APIs

Earthquakes, aircraft, the space station, the weather, satellite imagery, live OpenStreetMap, Wikipedia and species records, each from a public API that needs no key.

36

Earthquakes this week

The USGS publishes every earthquake of the past week as GeoJSON, updated every minute. A circle layer sizes and colours them by magnitude.

Waymorphic tilesUSGS earthquake feed
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/dark',
  center: [-100, 30],
  zoom: 2.2,
});

map.on('load', () => {
  map.addSource('quakes', { type: 'geojson', data: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_week.geojson', attribution: 'USGS' });
  map.addLayer({
    id: 'quakes', type: 'circle', source: 'quakes',
    paint: {
      'circle-radius': ['interpolate', ['linear'], ['get', 'mag'], 1, 2, 4, 8, 7, 30],
      'circle-color': ['interpolate', ['linear'], ['get', 'mag'], 1, '#fde68a', 4, '#f97316', 7, '#dc2626'],
      'circle-opacity': 0.75,
      'circle-stroke-color': '#fff', 'circle-stroke-width': 0.5,
    },
  });
  map.on('click', 'quakes', e => {
    const p = e.features[0].properties;
    new maplibregl.Popup().setLngLat(e.lngLat)
      .setHTML('<b>M ' + p.mag + '</b> ' + p.place + '<br>' + new Date(p.time).toUTCString() + '<br><a href="' + p.url + '" target="_blank">USGS page</a>')
      .addTo(map);
  });
  map.on('mouseenter', 'quakes', () => { map.getCanvas().style.cursor = 'pointer'; });
  map.on('mouseleave', 'quakes', () => { map.getCanvas().style.cursor = ''; });
});
preview
PreviewRuns when it scrolls into view

37

Aircraft in the air right now

The OpenSky Network gives the position and heading of every aircraft its receivers hear, for free without a key (a few hundred requests a day). An icon rotated by the heading shows each one.

Waymorphic tilesOpenSky Network
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [8.5, 47.5],
  zoom: 6,
});

// An arrow for the plane, drawn on a canvas, pointing up (north).
const c = document.createElement('canvas'); c.width = c.height = 24;
const g = c.getContext('2d');
g.fillStyle = '#1d4ed8';
g.beginPath(); g.moveTo(12, 1); g.lineTo(23, 22); g.lineTo(12, 17); g.lineTo(1, 22); g.closePath(); g.fill();

async function load() {
  const b = map.getBounds();
  const url = 'https://opensky-network.org/api/states/all?lamin=' + b.getSouth() + '&lomin=' + b.getWest() + '&lamax=' + b.getNorth() + '&lomax=' + b.getEast();
  const { states } = await (await fetch(url)).json();
  // Each state: [icao24, callsign, country, ..., longitude(5), latitude(6), altitude(7), on_ground(8), velocity(9), heading(10), ...]
  const features = (states || []).filter(s => s[5] != null && !s[8]).map(s => ({
    type: 'Feature', geometry: { type: 'Point', coordinates: [s[5], s[6]] },
    properties: { callsign: (s[1] || '').trim(), country: s[2], altitude: Math.round(s[7] || 0), heading: s[10] || 0, speed: Math.round((s[9] || 0) * 3.6) },
  }));
  map.getSource('planes').setData({ type: 'FeatureCollection', features });
  btn.textContent = features.length + ' aircraft · reload';
}

const btn = document.createElement('button');
btn.textContent = 'Load aircraft in view';
btn.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1;padding:6px 12px;border:0;border-radius:999px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer';
btn.onclick = load;
map.getContainer().append(btn);

map.on('load', () => {
  map.addImage('plane', g.getImageData(0, 0, 24, 24));
  map.addSource('planes', { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, attribution: 'OpenSky Network' });
  map.addLayer({
    id: 'planes', type: 'symbol', source: 'planes',
    layout: { 'icon-image': 'plane', 'icon-size': 0.8, 'icon-rotate': ['get', 'heading'], 'icon-rotation-alignment': 'map', 'icon-allow-overlap': true },
  });
  map.on('click', 'planes', e => {
    const p = e.features[0].properties;
    new maplibregl.Popup().setLngLat(e.lngLat).setHTML('<b>' + (p.callsign || 'no callsign') + '</b> · ' + p.country + '<br>' + p.altitude + ' m · ' + p.speed + ' km/h').addTo(map);
  });
});
preview
PreviewRuns when it scrolls into view

38

The space station, live

Where the ISS at returns the station's position every second. Poll it every few seconds, move a marker, and let the map follow on the globe.

Waymorphic tilesWhere the ISS at
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/fiord',
  center: [0, 0],
  zoom: 2.2,
});
map.on('style.load', () => map.setProjection({ type: 'globe' }));

const el = document.createElement('div');
el.style.cssText = 'width:26px;height:26px;border-radius:50%;background:#fff;border:3px solid #38bdf8;box-shadow:0 0 0 8px rgba(56,189,248,.3)';
const marker = new maplibregl.Marker({ element: el }).setLngLat([0, 0]).addTo(map);
const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 20 });
const trail = [];

map.on('load', () => {
  map.addSource('trail', { type: 'geojson', data: { type: 'LineString', coordinates: [] } });
  map.addLayer({ id: 'trail', type: 'line', source: 'trail', paint: { 'line-color': '#38bdf8', 'line-width': 2, 'line-dasharray': [2, 2] } });
});

async function tick() {
  const s = await (await fetch('https://api.wheretheiss.at/v1/satellites/25544')).json();
  const p = [s.longitude, s.latitude];
  marker.setLngLat(p);
  popup.setLngLat(p).setHTML('<b>ISS</b><br>' + Math.round(s.altitude) + ' km up · ' + Math.round(s.velocity) + ' km/h').addTo(map);
  trail.push(p);
  if (map.getSource('trail')) map.getSource('trail').setData({ type: 'LineString', coordinates: trail });
  map.easeTo({ center: p, duration: 4000, easing: t => t });
}
tick();
setInterval(tick, 5000);
preview
PreviewRuns when it scrolls into view

39

The temperature now, city by city

Open-Meteo answers for many places in one request, without a key. Colour a circle by the temperature and label it.

Waymorphic tilesOpen-Meteo
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [8, 53],
  zoom: 2.9,
});

const cities = [
  ['London', 51.51, -0.13], ['Paris', 48.86, 2.35], ['Berlin', 52.52, 13.41], ['Madrid', 40.42, -3.70], ['Rome', 41.90, 12.50],
  ['Vienna', 48.21, 16.37], ['Warsaw', 52.23, 21.01], ['Stockholm', 59.33, 18.07], ['Oslo', 59.91, 10.75], ['Helsinki', 60.17, 24.94],
  ['Dublin', 53.35, -6.26], ['Lisbon', 38.72, -9.14], ['Athens', 37.98, 23.73], ['Istanbul', 41.01, 28.98], ['Kyiv', 50.45, 30.52],
  ['Moscow', 55.76, 37.62], ['Reykjavik', 64.15, -21.94], ['Zurich', 47.38, 8.54], ['Prague', 50.08, 14.44], ['Budapest', 47.50, 19.04],
];

map.on('load', async () => {
  const url = 'https://api.open-meteo.com/v1/forecast?current=temperature_2m&latitude=' + cities.map(c => c[1]).join(',') + '&longitude=' + cities.map(c => c[2]).join(',');
  const results = await (await fetch(url)).json();
  const features = cities.map((c, i) => ({
    type: 'Feature', geometry: { type: 'Point', coordinates: [c[2], c[1]] },
    properties: { name: c[0], temp: results[i].current.temperature_2m },
  }));
  map.addSource('temps', { type: 'geojson', data: { type: 'FeatureCollection', features }, attribution: 'Weather data by Open-Meteo.com' });
  map.addLayer({
    id: 'temps', type: 'circle', source: 'temps',
    paint: {
      'circle-radius': 14,
      'circle-color': ['interpolate', ['linear'], ['get', 'temp'], -10, '#1e3a8a', 0, '#60a5fa', 10, '#a7f3d0', 20, '#fde047', 30, '#f97316', 40, '#b91c1c'],
      'circle-stroke-color': '#fff', 'circle-stroke-width': 2,
    },
  });
  map.addLayer({
    id: 'temp-labels', type: 'symbol', source: 'temps',
    layout: { 'text-field': ['concat', ['to-string', ['round', ['get', 'temp']]], '°'], 'text-font': ['Noto Sans Bold'], 'text-size': 12, 'text-allow-overlap': true },
    paint: { 'text-color': '#111' },
  });
  map.addLayer({
    id: 'city-labels', type: 'symbol', source: 'temps',
    layout: { 'text-field': ['get', 'name'], 'text-font': ['Noto Sans Regular'], 'text-size': 11, 'text-offset': [0, 1.5], 'text-anchor': 'top' },
    paint: { 'text-halo-color': '#fff', 'text-halo-width': 1.5 },
  });
});
preview
PreviewRuns when it scrolls into view

40

Rain radar, the last two hours

RainViewer serves a radar composite as raster tiles for the last frames. Add one raster source per frame and show them in turn for an animation.

Waymorphic tilesRainViewer
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/dark',
  center: [-2, 53],
  zoom: 5,
});

map.on('load', async () => {
  const api = await (await fetch('https://api.rainviewer.com/public/weather-maps.json')).json();
  const frames = api.radar.past;
  const firstSymbol = map.getStyle().layers.find(l => l.type === 'symbol').id;
  frames.forEach((f, i) => {
    map.addSource('radar-' + i, {
      type: 'raster', tileSize: 256,
      tiles: [api.host + f.path + '/256/{z}/{x}/{y}/2/1_1.png'],   // colour scheme 2, smoothed, with snow
      attribution: 'Radar: RainViewer',
    });
    map.addLayer({ id: 'radar-' + i, type: 'raster', source: 'radar-' + i, paint: { 'raster-opacity': 0 } }, firstSymbol);
  });

  const label = document.createElement('div');
  label.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1;padding:6px 12px;background:#fff;border-radius:999px;font:13px system-ui';
  map.getContainer().append(label);

  let i = 0;
  setInterval(() => {
    frames.forEach((f, j) => map.setPaintProperty('radar-' + j, 'raster-opacity', j === i ? 0.8 : 0));
    label.textContent = new Date(frames[i].time * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
    i = (i + 1) % frames.length;
  }, 600);
});
preview
PreviewRuns when it scrolls into view

41

This week's satellite picture

NASA GIBS serves a true-colour picture of the whole planet for every day as tiles, no key needed; this one is from VIIRS on the NOAA-20 satellite. Put it under the labels of a style and the map becomes a satellite map with names.

Waymorphic tilesNASA GIBS
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/dark',
  center: [12, 42],
  zoom: 5,
});

// GIBS adds each pass a few hours after it is taken, so the day before yesterday is complete everywhere.
// (MODIS on Terra, the old standby, stopped in December 2025; VIIRS on NOAA-20 is the daily picture now.)
const date = new Date(Date.now() - 2 * 864e5).toISOString().slice(0, 10);

map.on('load', () => {
  map.addSource('viirs', {
    type: 'raster', tileSize: 256, maxzoom: 9,
    tiles: ['https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_NOAA20_CorrectedReflectance_TrueColor/default/' + date + '/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg'],
    attribution: 'Imagery: NASA GIBS, VIIRS NOAA-20, ' + date,
  });
  // Under the labels: before the first symbol layer of the style.
  const firstSymbol = map.getStyle().layers.find(l => l.type === 'symbol').id;
  map.addLayer({ id: 'viirs', type: 'raster', source: 'viirs' }, firstSymbol);
});
preview
PreviewRuns when it scrolls into view

42

Earth at night

The VIIRS city lights composite from NASA GIBS, blended over the dark style with a raster layer at partial opacity. The city names come from the tiles.

Waymorphic tilesNASA GIBS
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/dark',
  center: [78, 22],
  zoom: 3.5,
});

map.on('load', () => {
  map.addSource('lights', {
    type: 'raster', tileSize: 256, maxzoom: 8,
    tiles: ['https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_CityLights_2012/default/2012-01-01/GoogleMapsCompatible_Level8/{z}/{y}/{x}.jpg'],
    attribution: 'Night lights: NASA GIBS, VIIRS 2012',
  });
  const firstSymbol = map.getStyle().layers.find(l => l.type === 'symbol').id;
  map.addLayer({ id: 'lights', type: 'raster', source: 'lights', paint: { 'raster-opacity': 0.9 } }, firstSymbol);
});
preview
PreviewRuns when it scrolls into view

43

Live OpenStreetMap: cafés in view

The Overpass API answers questions about the live OpenStreetMap database. Ask for every café in the current view and draw the answer; the tiles are the map, Overpass is the data.

Waymorphic tilesOverpass API
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [-0.1276, 51.5074],
  zoom: 14.5,
});

async function load() {
  btn.textContent = 'Loading…';
  const b = map.getBounds();
  const bbox = [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].join(',');
  const query = '[out:json][timeout:25];node["amenity"="cafe"](' + bbox + ');out;';
  const res = await fetch('https://overpass-api.de/api/interpreter', { method: 'POST', body: 'data=' + encodeURIComponent(query) });
  const { elements } = await res.json();
  map.getSource('cafes').setData({ type: 'FeatureCollection', features: elements.map(n => ({
    type: 'Feature', geometry: { type: 'Point', coordinates: [n.lon, n.lat] }, properties: { name: n.tags.name || 'Café' },
  })) });
  btn.textContent = elements.length + ' cafés · search this area';
}

const btn = document.createElement('button');
btn.textContent = 'Search cafés in view';
btn.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1;padding:6px 12px;border:0;border-radius:999px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer';
btn.onclick = load;
map.getContainer().append(btn);

map.on('load', () => {
  map.addSource('cafes', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  map.addLayer({ id: 'cafes', type: 'circle', source: 'cafes', paint: { 'circle-radius': 6, 'circle-color': '#92400e', 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 } });
  map.addLayer({
    id: 'cafe-names', type: 'symbol', source: 'cafes', minzoom: 15,
    layout: { 'text-field': ['get', 'name'], 'text-font': ['Noto Sans Regular'], 'text-size': 11, 'text-offset': [0, 0.9], 'text-anchor': 'top', 'text-optional': true },
    paint: { 'text-color': '#92400e', 'text-halo-color': '#fff', 'text-halo-width': 1.5 },
  });
  load();
});
preview
PreviewRuns when it scrolls into view

44

Wikipedia articles around here

Wikipedia's geosearch lists the articles with coordinates near a point. A marker per article, a popup with a link, refreshed when the map stops moving.

Waymorphic tilesWikipedia
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/bright',
  center: [-73.9855, 40.7580],
  zoom: 15,
});

let markers = [];
async function load() {
  const c = map.getCenter();
  const url = 'https://en.wikipedia.org/w/api.php?action=query&list=geosearch&gsradius=1500&gslimit=40&format=json&origin=*&gscoord=' + c.lat + '|' + c.lng;
  const { query } = await (await fetch(url)).json();
  for (const m of markers) m.remove();
  markers = query.geosearch.map(a =>
    new maplibregl.Marker({ color: '#0f766e', scale: 0.8 })
      .setLngLat([a.lon, a.lat])
      .setPopup(new maplibregl.Popup({ offset: 25 }).setHTML('<a href="https://en.wikipedia.org/?curid=' + a.pageid + '" target="_blank"><b>' + a.title + '</b></a>'))
      .addTo(map));
}
map.on('load', load);
map.on('moveend', load);
preview
PreviewRuns when it scrolls into view

45

Where a species has been seen

GBIF renders its two billion occurrence records as map tiles, per species, without a key. This is every record of the red fox; change the taxonKey for any other species.

Waymorphic tilesGBIF
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [10, 50],
  zoom: 3,
});

map.on('load', () => {
  map.addSource('fox', {
    type: 'raster', tileSize: 512,
    // taxonKey 5219243 is Vulpes vulpes; find others at https://www.gbif.org/species/search
    tiles: ['https://api.gbif.org/v2/map/occurrence/density/{z}/{x}/{y}@1x.png?taxonKey=5219243&style=purpleHeat.point'],
    attribution: 'Occurrences: GBIF',
  });
  const firstSymbol = map.getStyle().layers.find(l => l.type === 'symbol').id;
  map.addLayer({ id: 'fox', type: 'raster', source: 'fox', paint: { 'raster-opacity': 0.85 } }, firstSymbol);
});
preview
PreviewRuns when it scrolls into view

46

Day and night

The line between day and night follows from the date and the time alone: the sun's declination and its longitude give the point under the sun, and the night is the half of the sphere facing away. Computed here, no data fetched.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [0, 20],
  zoom: 1.4,
});
map.on('style.load', () => map.setProjection({ type: 'globe' }));

// The point on the Earth directly under the sun, from a low-precision solar position (Astronomical Almanac).
function subsolar(date) {
  const d = date / 864e5 - 10957.5;                      // days since J2000.0
  const rad = Math.PI / 180;
  const M = (357.529 + 0.98560028 * d) * rad;            // mean anomaly
  const L = (280.459 + 0.98564736 * d) * rad;            // mean longitude
  const lambda = L + (1.915 * Math.sin(M) + 0.020 * Math.sin(2 * M)) * rad;
  const eps = (23.439 - 0.00000036 * d) * rad;
  const dec = Math.asin(Math.sin(eps) * Math.sin(lambda));
  const ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda));
  const gmst = (18.697374558 + 24.06570982441908 * d) % 24;
  const lng = ((ra / rad - gmst * 15) % 360 + 540) % 360 - 180;
  return { lat: dec / rad, lng };
}

// The night polygon: the terminator from west to east, closed round the dark pole.
function night(date) {
  const s = subsolar(date), rad = Math.PI / 180, ring = [];
  for (let lng = -180; lng <= 180; lng += 2) {
    const H = (lng - s.lng) * rad;
    ring.push([lng, Math.atan(-Math.cos(H) / Math.tan(s.lat * rad)) / rad]);
  }
  const pole = s.lat > 0 ? -90 : 90;
  ring.push([180, pole], [-180, pole], ring[0]);
  return { type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } };
}

map.on('load', () => {
  map.addSource('night', { type: 'geojson', data: night(new Date()) });
  map.addLayer({ id: 'night', type: 'fill', source: 'night', paint: { 'fill-color': '#0b1a33', 'fill-opacity': 0.45 } });
  const s = subsolar(new Date());
  new maplibregl.Marker({ color: '#f59e0b' }).setLngLat([s.lng, s.lat]).setPopup(new maplibregl.Popup().setText('The sun is overhead here')).addTo(map);
  setInterval(() => map.getSource('night').setData(night(new Date())), 60000);
});
preview
PreviewRuns when it scrolls into view

Search, routes and places

Search, addresses and place lookups from Waymorphic Map's own service on the tile domain; routing, drive-time areas, elevation and great-circle distances from public OpenStreetMap-based services.

48

The MapLibre geocoder control

maplibre-gl-geocoder is the search control most MapLibre apps use. It takes a forwardGeocode function; a few lines point it at Waymorphic Map's search, whose features already carry the place_name, center and bbox the control reads.

map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/bright',
  center: [2.35, 48.86],
  zoom: 11,
});

// The control's stylesheet, from the same package.
const css = document.createElement('link');
css.rel = 'stylesheet';
css.href = 'https://cdn.jsdelivr.net/npm/@maplibre/[email protected]/dist/maplibre-gl-geocoder.css';
document.head.append(css);

const geocoderApi = {
  forwardGeocode: async config => {
    const url = 'https://tiles.waymorphic.com/search?limit=' + (config.limit || 5) + '&q=' + encodeURIComponent(config.query) + (config.proximity ? '&lon=' + config.proximity[0] + '&lat=' + config.proximity[1] : '');
    return (await fetch(url)).json();   // already a Carmen-shaped FeatureCollection
  },
  reverseGeocode: async config => (await fetch('https://tiles.waymorphic.com/reverse?lon=' + config.query[0] + '&lat=' + config.query[1])).json(),
};
map.addControl(new MaplibreGeocoder(geocoderApi, { maplibregl, placeholder: 'Search', proximity: true, reverseGeocode: true }), 'top-left');
preview
PreviewRuns when it scrolls into view

49

Click for the address

The tile domain's /reverse turns a coordinate into an address: the nearest house number and street from the tile under the point, with the postcode, city, state and country. One request per click, with a popup.

Waymorphic tilesWaymorphic Map search
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/bright',
  center: [-0.1276, 51.5074],
  zoom: 15,
});

map.on('click', async e => {
  const popup = new maplibregl.Popup().setLngLat(e.lngLat).setText('Looking up…').addTo(map);
  const url = 'https://tiles.waymorphic.com/reverse?lat=' + e.lngLat.lat.toFixed(6) + '&lon=' + e.lngLat.lng.toFixed(6);
  const { features } = await (await fetch(url)).json();
  if (!features.length) return popup.setText('Nothing here.');
  const p = features[0].properties;
  popup.setHTML('<b>' + p.text + '</b><br>' + p.label + '<br><small>' + Math.round(p.distance) + ' m away · © OpenStreetMap contributors</small>');
});
map.getCanvas().style.cursor = 'crosshair';
preview
PreviewRuns when it scrolls into view

50

The address under a pin

A coordinate in, an address out. The pin starts on 221B Baker Street, the address of Sherlock Holmes, and one request to /reverse names the house number, the street, the postcode and the city under it, with how far the match is from the pin. Drag the pin anywhere and it asks again.

Waymorphic tilesWaymorphic Map search
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/bright',
  center: [-0.1586, 51.5238],   // 221B Baker Street, London
  zoom: 16,
});

const marker = new maplibregl.Marker({ draggable: true, color: '#e11d48' })
  .setLngLat([-0.1586, 51.5238])
  .setPopup(new maplibregl.Popup({ offset: 36, closeButton: false }))
  .addTo(map);

async function lookup() {
  const { lng, lat } = marker.getLngLat();
  const popup = marker.getPopup().setText('Looking up…');
  if (!popup.isOpen()) marker.togglePopup();
  const url = 'https://tiles.waymorphic.com/reverse?lat=' + lat.toFixed(6) + '&lon=' + lng.toFixed(6);
  const { features } = await (await fetch(url)).json();
  if (!features.length) return popup.setText('Nothing here.');
  const p = features[0].properties;   // housenumber, street, postcode, city, ... and distance
  const line1 = [p.housenumber, p.street].filter(Boolean).join(' ') || p.name;
  const line2 = [p.postcode, p.city, p.country].filter(Boolean).join(', ');
  popup.setHTML('<b>' + line1 + '</b><br>' + line2 + '<br><small>' + Math.round(p.distance) + ' m from the pin · © OpenStreetMap contributors</small>');
}
marker.on('dragend', lookup);
map.on('load', lookup);
preview
PreviewRuns when it scrolls into view

51

Search only what is in view

The search takes filters: bbox keeps the matches inside the map's bounds, layer picks the kind of feature (place, street, poi, aerodrome, peak, water, park or postcode), countrycodes the countries, and lang the language of the names. Here it lists the points of interest in view whose name matches, with a marker each, and looks again when the map stops moving.

Waymorphic tilesWaymorphic Map search
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/liberty',
  center: [-0.12, 51.51],
  zoom: 14,
});

const box = document.createElement('div');
box.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1;width:260px;font:14px system-ui';
box.innerHTML = '<input value="museum" style="width:100%;padding:9px 12px;border:0;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.3);font:inherit"><div style="margin-top:6px;max-height:260px;overflow:auto;background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.3)"></div>';
map.getContainer().append(box);
const input = box.firstChild, list = box.lastChild;
const markers = [];

async function run() {
  const q = input.value.trim();
  if (!q) { list.replaceChildren(); return; }
  const b = map.getBounds();   // bbox is minLon,minLat,maxLon,maxLat
  const url = 'https://tiles.waymorphic.com/search?limit=20&layer=poi&q=' + encodeURIComponent(q)
    + '&bbox=' + [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].map(v => v.toFixed(4)).join(',');
  const { features } = await (await fetch(url)).json();
  markers.splice(0).forEach(m => m.remove());
  list.replaceChildren(...features.map(f => {
    const p = f.properties;
    markers.push(new maplibregl.Marker({ color: '#e11d48' }).setLngLat(f.geometry.coordinates).setPopup(new maplibregl.Popup().setText(p.label)).addTo(map));
    const row = document.createElement('div');
    row.textContent = p.name + (p.street ? ' · ' + p.street : '');
    row.style.cssText = 'padding:7px 12px;cursor:pointer;border-top:1px solid #eee';
    row.onclick = () => map.flyTo({ center: f.geometry.coordinates, zoom: 17 });
    return row;
  }));
  if (!features.length) list.textContent = 'Nothing in view with that name.';
}
let timer;
input.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 250); };
map.on('moveend', run);   // the view changed: search again
map.on('load', run);
preview
PreviewRuns when it scrolls into view

52

A route between two clicks

OSRM's demo server routes on OpenStreetMap roads. Two clicks, one request, and the line comes back as GeoJSON with the distance and the time.

Waymorphic tilesOSRM
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [-3.7, 40.42],
  zoom: 12,
});

const out = document.createElement('div');
out.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1;padding:8px 12px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.3);font:13px system-ui';
out.textContent = 'Click the start, then the end.';
map.getContainer().append(out);

const stops = [], markers = [];
map.on('load', () => {
  map.addSource('route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  map.addLayer({ id: 'route', type: 'line', source: 'route', layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#2563eb', 'line-width': 5, 'line-opacity': 0.8 } });
});

map.on('click', async e => {
  if (stops.length === 2) { stops.length = 0; markers.splice(0).forEach(m => m.remove()); map.getSource('route').setData({ type: 'FeatureCollection', features: [] }); }
  stops.push([e.lngLat.lng, e.lngLat.lat]);
  markers.push(new maplibregl.Marker({ color: stops.length === 1 ? '#16a34a' : '#dc2626' }).setLngLat(e.lngLat).addTo(map));
  if (stops.length < 2) { out.textContent = 'Now click the end.'; return; }

  const url = 'https://router.project-osrm.org/route/v1/driving/' + stops.map(s => s.join(',')).join(';') + '?overview=full&geometries=geojson';
  const { routes } = await (await fetch(url)).json();
  const r = routes[0];
  map.getSource('route').setData(r.geometry);
  out.textContent = (r.distance / 1000).toFixed(1) + ' km · ' + Math.round(r.duration / 60) + ' min by car';
});
preview
PreviewRuns when it scrolls into view

53

How far in 10, 20 and 30 minutes

Valhalla, run by FOSSGIS for the OpenStreetMap community, computes drive-time areas. One request returns a polygon per contour, ready to draw.

Waymorphic tilesValhalla at FOSSGIS
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [11.58, 48.14],
  zoom: 9,             // wide enough for the 30-minute area
});

async function isochrone(lngLat) {
  const req = {
    locations: [{ lat: lngLat.lat, lon: lngLat.lng }],
    costing: 'auto',
    contours: [{ time: 10 }, { time: 20 }, { time: 30 }],
    polygons: true,
  };
  const url = 'https://valhalla1.openstreetmap.de/isochrone?json=' + encodeURIComponent(JSON.stringify(req));
  map.getSource('iso').setData(await (await fetch(url)).json());
}

map.on('load', () => {
  map.addSource('iso', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  const firstSymbol = map.getStyle().layers.find(l => l.type === 'symbol').id;
  map.addLayer({
    id: 'iso', type: 'fill', source: 'iso',
    paint: { 'fill-color': ['match', ['get', 'contour'], 10, '#16a34a', 20, '#f59e0b', '#dc2626'], 'fill-opacity': 0.25 },
  }, firstSymbol);
  map.addLayer({ id: 'iso-line', type: 'line', source: 'iso', paint: { 'line-color': ['match', ['get', 'contour'], 10, '#16a34a', 20, '#f59e0b', '#dc2626'], 'line-width': 1.5 } }, firstSymbol);
  isochrone(map.getCenter());
});

const marker = new maplibregl.Marker({ draggable: true }).setLngLat([11.58, 48.14]).addTo(map);
marker.on('dragend', () => isochrone(marker.getLngLat()));
preview
PreviewRuns when it scrolls into view

54

Click for the elevation

Open-Meteo's elevation API answers a coordinate with its height above sea level, from a 90 m model, without a key.

Waymorphic tilesOpen-Meteo
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/liberty',
  center: [-106.4, 39.0],
  zoom: 9,
});

map.on('click', async e => {
  const popup = new maplibregl.Popup().setLngLat(e.lngLat).setText('…').addTo(map);
  const r = await (await fetch('https://api.open-meteo.com/v1/elevation?latitude=' + e.lngLat.lat + '&longitude=' + e.lngLat.lng)).json();
  const m = r.elevation[0];
  popup.setHTML('<b>' + Math.round(m) + ' m</b> · ' + Math.round(m * 3.28084) + ' ft');
});
map.getCanvas().style.cursor = 'crosshair';
preview
PreviewRuns when it scrolls into view

55

The distance between two clicks

The shortest path on the sphere is a great circle, and it curves on a flat map. The haversine formula gives the length; a hundred points along the arc draw it.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/fiord',
  center: [-30, 40],
  zoom: 2,
});

const rad = Math.PI / 180, R = 6371;
function distance(a, b) {
  const dLat = (b[1] - a[1]) * rad, dLng = (b[0] - a[0]) * rad;
  const h = Math.sin(dLat / 2) ** 2 + Math.cos(a[1] * rad) * Math.cos(b[1] * rad) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(h));
}
function arc(a, b, n = 100) {           // points along the great circle from a to b
  const [l1, p1, l2, p2] = [a[0] * rad, a[1] * rad, b[0] * rad, b[1] * rad];
  const d = 2 * Math.asin(Math.sqrt(Math.sin((p2 - p1) / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin((l2 - l1) / 2) ** 2));
  return Array.from({ length: n + 1 }, (_, i) => {
    const f = i / n, A = Math.sin((1 - f) * d) / Math.sin(d), B = Math.sin(f * d) / Math.sin(d);
    const x = A * Math.cos(p1) * Math.cos(l1) + B * Math.cos(p2) * Math.cos(l2);
    const y = A * Math.cos(p1) * Math.sin(l1) + B * Math.cos(p2) * Math.sin(l2);
    const z = A * Math.sin(p1) + B * Math.sin(p2);
    return [Math.atan2(y, x) / rad, Math.atan2(z, Math.hypot(x, y)) / rad];
  });
}

const out = document.createElement('div');
out.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1;padding:8px 12px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.3);font:13px system-ui';
out.textContent = 'Click two places.';
map.getContainer().append(out);

const pts = [];
map.on('load', () => {
  map.addSource('arc', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  map.addLayer({ id: 'arc', type: 'line', source: 'arc', paint: { 'line-color': '#f59e0b', 'line-width': 3 } });
});
map.on('click', e => {
  if (pts.length === 2) pts.length = 0;
  pts.push([e.lngLat.lng, e.lngLat.lat]);
  if (pts.length < 2) { map.getSource('arc').setData({ type: 'FeatureCollection', features: [] }); out.textContent = 'Now the second place.'; return; }
  map.getSource('arc').setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: arc(pts[0], pts[1]) } });
  const km = distance(pts[0], pts[1]);
  out.textContent = Math.round(km).toLocaleString() + ' km · ' + Math.round(km * 0.621371).toLocaleString() + ' miles';
});
preview
PreviewRuns when it scrolls into view

Interface patterns

Two maps in step, a swipe between styles, custom controls, a theme that follows the OS, the visitor's position, and the date of the map data.

56

Swipe between two styles

Two maps on top of each other, moved together, with the top one clipped at a slider. No plugin: a clip-path and a range input.

Waymorphic tiles
map.js
const view = { center: [-0.1276, 51.5074], zoom: 12 };
const left = new maplibregl.Map({ container: 'map', style: 'https://tiles.waymorphic.com/styles/positron', ...view, attributionControl: false });
const right = new maplibregl.Map({ container: 'map2', style: 'https://tiles.waymorphic.com/styles/dark', ...view });

// Keep the two in step: whichever the visitor moves drives the other.
let busy = false;
function follow(from, to) {
  from.on('move', () => {
    if (busy) return;
    busy = true;
    to.jumpTo({ center: from.getCenter(), zoom: from.getZoom(), bearing: from.getBearing(), pitch: from.getPitch() });
    busy = false;
  });
}
follow(left, right); follow(right, left);

const swipe = document.getElementById('swipe');
swipe.oninput = () => { document.getElementById('map2').style.clipPath = 'inset(0 0 0 ' + swipe.value + '%)'; };
index.html
<style>
  #map, #map2 { position: absolute; inset: 0; } #map2 { clip-path: inset(0 0 0 50%); } #swipe { position: absolute; z-index: 2; left: 0; right: 0; top: 20px; width: 100%; margin: 0; }
</style>
<div id="map"></div><div id="map2"></div><input id="swipe" type="range" min="0" max="100" value="50">
preview
PreviewRuns when it scrolls into view

57

Two maps side by side, in step

Side by side, the same trick keeps them together. Here the same place in Bright and in the satellite picture from NASA GIBS.

Waymorphic tilesNASA GIBS
map.js
const view = { center: [-122.42, 37.77], zoom: 8.5 };
const date = new Date(Date.now() - 2 * 864e5).toISOString().slice(0, 10);   // the day before yesterday: complete everywhere
const a = new maplibregl.Map({ container: 'map', style: 'https://tiles.waymorphic.com/styles/bright', ...view });
const b = new maplibregl.Map({
  container: 'map2', ...view,
  style: {
    version: 8,
    sources: { viirs: { type: 'raster', tileSize: 256, maxzoom: 9, attribution: 'NASA GIBS, VIIRS NOAA-20, ' + date,
      tiles: ['https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_NOAA20_CorrectedReflectance_TrueColor/default/' + date + '/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg'] } },
    layers: [{ id: 'viirs', type: 'raster', source: 'viirs' }],
  },
});

let busy = false;
function follow(from, to) {
  from.on('move', () => {
    if (busy) return;
    busy = true;
    to.jumpTo({ center: from.getCenter(), zoom: from.getZoom(), bearing: from.getBearing(), pitch: from.getPitch() });
    busy = false;
  });
}
follow(a, b); follow(b, a);
index.html
<style>
  #map, #map2 { position: absolute; top: 0; bottom: 0; width: 50%; } #map { left: 0; } #map2 { right: 0; }
</style>
<div id="map"></div><div id="map2"></div>
preview
PreviewRuns when it scrolls into view

58

A custom control

A control is an object with onAdd and onRemove that returns an element. MapLibre places it in a corner with the other controls, in the same style.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [139.767, 35.681],
  zoom: 12,
  bearing: 35,
  pitch: 40,
});

class HomeControl {
  constructor(view) { this.view = view; }
  onAdd(map) {
    this.map = map;
    this.el = document.createElement('div');
    this.el.className = 'maplibregl-ctrl maplibregl-ctrl-group';   // the built-in look
    const b = document.createElement('button');
    b.type = 'button'; b.title = 'Back to the start'; b.textContent = '⌂';
    b.style.fontSize = '18px';
    b.onclick = () => this.map.flyTo({ ...this.view, bearing: 0, pitch: 0 });
    this.el.append(b);
    return this.el;
  }
  onRemove() { this.el.remove(); this.map = null; }
}

map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }));
map.addControl(new HomeControl({ center: [139.767, 35.681], zoom: 12 }), 'top-right');
preview
PreviewRuns when it scrolls into view

59

Dark or light with the OS

prefers-color-scheme says which the visitor wants. Load Dark or Positron to match, and switch when the setting changes.

Waymorphic tiles
map.js
const dark = matchMedia('(prefers-color-scheme: dark)');
const styleFor = isDark => 'https://tiles.waymorphic.com/styles/' + (isDark ? 'dark' : 'positron');

const map = new maplibregl.Map({
  container: 'map',
  style: styleFor(dark.matches),
  center: [-79.38, 43.65],
  zoom: 12,
});

dark.addEventListener('change', e => map.setStyle(styleFor(e.matches)));
preview
PreviewRuns when it scrolls into view

60

The visitor's position

The GeolocateControl asks for permission on the first press, shows a dot with its accuracy, and can keep following. Its events carry the coordinates for your own use.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/bright',
  center: [0, 30],
  zoom: 1.5,
});

const locate = new maplibregl.GeolocateControl({
  positionOptions: { enableHighAccuracy: true },
  trackUserLocation: true,        // keep the dot moving with the device
  showAccuracyCircle: true,
});
map.addControl(locate);

const out = document.createElement('div');
out.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1;padding:8px 12px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.3);font:13px system-ui';
out.textContent = 'Press the locate button.';
map.getContainer().append(out);

locate.on('geolocate', e => {
  out.textContent = e.coords.latitude.toFixed(5) + ', ' + e.coords.longitude.toFixed(5) + ' · ±' + Math.round(e.coords.accuracy) + ' m';
});
locate.on('error', e => { out.textContent = 'No position: ' + e.message; });
preview
PreviewRuns when it scrolls into view

61

How fresh is the map?

The TileJSON at /planet describes the live tileset: its bounds, its zoom range and the date of the OpenStreetMap data it was built from. Read it and tell the visitor.

Waymorphic tiles
map.js
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.waymorphic.com/styles/positron',
  center: [0, 30],
  zoom: 1.2,
});

const out = document.createElement('div');
out.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1;padding:8px 12px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.3);font:13px system-ui';
map.getContainer().append(out);

fetch('https://tiles.waymorphic.com/planet').then(r => r.json()).then(tj => {
  // The description names the build, e.g. "planet_20260901_..."; the digits are the date of the OSM data.
  const m = /(\d{4})(\d{2})(\d{2})/.exec(tj.description || '');
  out.innerHTML = '<b>' + tj.name + '</b> · zoom ' + tj.minzoom + ' to ' + tj.maxzoom +
    (m ? '<br>OpenStreetMap data as of ' + m[1] + '-' + m[2] + '-' + m[3] : '') +
    '<br><small>' + tj.description + '</small>';
  map.on('load', () => map.fitBounds([[tj.bounds[0], tj.bounds[1]], [tj.bounds[2], tj.bounds[3]]], { padding: 20, duration: 0 }));
}).catch(() => { out.textContent = 'The tile server is not answering.'; });
preview
PreviewRuns when it scrolls into view

No example matches that. Try another word, or clear the search.

Now make it yours.

Start from the snippet closest to what you need. The style URL is free to use, the data is free to use, and there is nothing to sign up for.