Vector map tiles in OpenLayers
OpenLayers draws vector tiles itself, and the ol-mapbox-style package turns a MapLibre style into OpenLayers layers. With script tags and with Vite, markers and GeoJSON on top, which of our styles OpenLayers draws, and MapLibre as an OpenLayers layer for the others. Tested with OpenLayers 10.11.0 and ol-mapbox-style 13.9.0.
<link rel="stylesheet" href="https://unpkg.com/ol@10.11.0/ol.css">
<script src="https://unpkg.com/ol@10.11.0/dist/ol.js"></script>
<script src="https://unpkg.com/ol-mapbox-style@13.9.0/dist/olms.js"></script>
<style>#map { height: 480px; }</style>
<div id="map"></div>
<script>
const base = new ol.layer.Group();
olms.apply(base, "https://api.overpass.ch/v1/YOUR_KEY/maps/styles/light.json?lang=en");
const map = new ol.Map({
target: "map",
layers: [base],
view: new ol.View({ center: ol.proj.fromLonLat([8.54, 47.37]), zoom: 13 }),
controls: ol.control.defaults.defaults({ attributionOptions: { collapsible: false } })
});
</script>
With script tags
The full build of OpenLayers, dist/ol.js, defines the global ol, and dist/olms.js of ol-mapbox-style defines olms. olms.apply loads the style and fills the layer group with OpenLayers layers for its sources and layers. Put the base map in a layer group: layers you add to the map later stay above it.
OpenLayers counts zoom levels in tiles of 256 pixels, like Leaflet, so zoom 13 in OpenLayers shows the same area as zoom 12 in MapLibre. The view works in Web Mercator coordinates; ol.proj.fromLonLat converts longitude and latitude.
Keep the classes in the ol namespace in a classic script:
const { Map, View } = ol; // the map stays empty
A const at the top level of a classic script is visible to every script of the page. This one hides the Map built into the browser, which ol-mapbox-style needs, and the console shows “TypeError: seen.has is not a function”. Write ol.Map, or put your code in a function or a module.
Markers, popups and GeoJSON
Markers are features in a vector layer, a popup is an overlay, and GeoJSON loads into a vector source. Add them after the base map:
<style>
.popup { background: #fff; padding: 4px 8px; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,.3); }
</style>
<div id="popup" class="popup"></div>
<script>
// after the map code above
const red = new ol.style.Style({
image: new ol.style.Circle({
radius: 7,
fill: new ol.style.Fill({ color: "#d0312d" }),
stroke: new ol.style.Stroke({ color: "#fff", width: 2 })
})
});
const places = new ol.layer.Vector({
source: new ol.source.Vector({
features: [
new ol.Feature({ geometry: new ol.geom.Point(ol.proj.fromLonLat([8.5402, 47.3782])), name: "Zürich HB" })
]
}),
style: red
});
const shops = new ol.layer.Vector({
source: new ol.source.Vector({ url: "shops.geojson", format: new ol.format.GeoJSON() }),
style: red
});
map.addLayer(places);
map.addLayer(shops);
const popup = new ol.Overlay({ element: document.getElementById("popup"), positioning: "bottom-center", offset: [0, -12] });
map.addOverlay(popup);
map.on("click", (e) => {
const f = map.forEachFeatureAtPixel(e.pixel, (f) => f, { layerFilter: (l) => l === places || l === shops });
popup.setPosition(f ? f.getGeometry().getCoordinates() : undefined);
if (f) popup.getElement().textContent = f.get("name");
});
</script>
ol.format.GeoJSONreads longitude and latitude and converts them to the projection of the map.- The base map has features too. Without the layer filter, a click on a road finds one of them, and the code fails with “TypeError: f.getGeometry(...).getCoordinates is not a function”. The filter is called with each layer inside the group, never with the group itself, so name the layers you want.
With npm and Vite
npm install ol ol-mapbox-style @fontsource/noto-sans
This ran in a Vite 8.3 build:
import "ol/ol.css";
import "@fontsource/noto-sans/400.css";
import "@fontsource/noto-sans/500.css";
import "@fontsource/noto-sans/400-italic.css";
import Map from "ol/Map.js";
import View from "ol/View.js";
import LayerGroup from "ol/layer/Group.js";
import { fromLonLat } from "ol/proj.js";
import { defaults as defaultControls } from "ol/control/defaults.js";
import { apply } from "ol-mapbox-style";
const KEY = import.meta.env.VITE_OVERPASS_KEY;
const base = new LayerGroup();
apply(base, `https://api.overpass.ch/v1/${KEY}/maps/styles/light.json?lang=en`);
const map = new Map({
target: "map",
layers: [base],
view: new View({ center: fromLonLat([8.54, 47.37]), zoom: 13 }),
controls: defaultControls({ attributionOptions: { collapsible: false } }),
});
Vite reads VITE_OVERPASS_KEY from a .env file next to package.json. The key still ends up in the JavaScript of the page, so limit it to your website (below). In a module, Map is a name of that module only, and the problem with const { Map } does not occur.
Fonts
OpenLayers draws labels with the fonts of the browser. Our styles use Noto Sans in three cuts: regular, medium and italic. When the page does not have them, ol-mapbox-style loads them from cdn.jsdelivr.net, 18 to 27 requests on the first map in our tests.
To keep the browsers of your visitors away from that server, load the font before the map. With Vite, the three @fontsource/noto-sans imports above do it. With script tags, copy 400.css, 500.css, 400-italic.css and the files folder of the npm package @fontsource/noto-sans to your server, and give ol-mapbox-style their address:
olms.apply(base, "https://api.overpass.ch/v1/YOUR_KEY/maps/styles/light.json?lang=en", {
webfonts: "/fonts/{font-family}/{fontweight}{-fontstyle}.css"
});
ol-mapbox-style fills in noto-sans, 400 or 500, and -italic, so the files need to be at /fonts/noto-sans/400.css and so on.
Which styles OpenLayers draws
| Style | With ol-mapbox-style |
|---|---|
| light, dark, white, grayscale, black | Yes |
| outdoor | Yes, with trails by grade, huts, peaks, contour lines and hillshading |
| driving | With thin roads: the outlines that make them wide are missing |
| marine | The sea in one blue, without the colours by depth; sea marks and depth contours as in MapLibre |
| relief | Grey hillshading only, without the colours by height |
ol-mapbox-style 13.9 lacks three parts of the MapLibre style specification that our styles use:
color-relieflayers, which colour the land by height and the sea by depth. The console shows “layers[2].type "color-relief" not supported”.line-gap-width, which draws the outlines of roads.text-variable-anchor, so some labels sit on their icon.
Labels in other languages work with ?lang=de, and &hillshade=1 adds hillshading, as in MapLibre.
All nine styles: MapLibre as a layer
For relief, marine and driving as they look in MapLibre, let MapLibre draw the base map inside OpenLayers. The package @geoblocks/ol-maplibre-layer puts a MapLibre map into an OpenLayers layer; your OpenLayers layers, overlays and controls stay as they are. It needs a bundler. This ran in a Vite 8.3 build with MapLibre 6.16.0:
npm install ol @geoblocks/ol-maplibre-layer maplibre-gl
import "ol/ol.css";
import "maplibre-gl/dist/maplibre-gl.css";
import Map from "ol/Map.js";
import View from "ol/View.js";
import { fromLonLat } from "ol/proj.js";
import { defaults as defaultControls } from "ol/control/defaults.js";
import { MapLibreLayer } from "@geoblocks/ol-maplibre-layer";
import { setWorkerUrl } from "maplibre-gl";
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
setWorkerUrl(workerUrl);
const KEY = import.meta.env.VITE_OVERPASS_KEY;
const base = new MapLibreLayer({
mapLibreOptions: { style: `https://api.overpass.ch/v1/${KEY}/maps/styles/relief.json?lang=en` },
});
const map = new Map({
target: "map",
layers: [base],
view: new View({ center: fromLonLat([7.62, 46.12]), zoom: 10.6 }),
controls: defaultControls({ attributionOptions: { collapsible: false } }),
});
- MapLibre 6 needs
setWorkerUrlwith every bundler; for webpack, see the MapLibre migration guide. - The MapLibre map is
base.mapLibreMap, from the moment the layer is on a map. The README of version 2.0.0 calls itmaplibreMap, which is undefined. The layer firesloadwhen the style has loaded. - MapLibre draws the labels with the fonts from the API, so the fonts section above does not apply.
Download sizes
| Setup | JavaScript, gzip |
|---|---|
Script tags: ol.js and olms.js | 290 KB + 40 KB |
| Vite: OpenLayers and ol-mapbox-style, the code above | 151 KB |
| Vite: OpenLayers, ol-maplibre-layer and MapLibre | 332 KB, and 143 KB for the MapLibre worker |
Vite takes only the parts of OpenLayers you import; the full build has all of them.
Your key and what counts
- Your key is visible in the page. Create a key of the type “Maps only” in your dashboard and enter your website as an allowed referer, and
localhost:5173for the Vite dev server. - Every vector tile counts as one map tile, as with MapLibre. Every account includes 100'000 map tiles a month.
collapsible: falsekeeps the OpenStreetMap credit visible in the corner of the map, as its licence requires. Without it, OpenLayers folds the credits into an “i” button.
Limits
- OpenLayers cannot tilt the map, so
terrain=1and 3D buildings have no use here. It rotates: drag with Alt and Shift held, or turn with two fingers. - OpenLayers places labels in its own way and shows fewer street names than MapLibre.
- For the same map in Leaflet, see Vector map tiles in Leaflet; how the libraries differ is in MapLibre vs Leaflet vs OpenLayers.