The web maps SDK wraps MapLibre, it does not fork it
@mapmap/maps is a TypeScript wrapper over MapLibre GL JS, published on npm at version 0.1.0, that gives you a MapMap map with our vector tiles, styles and truck routing already wired in. It is a wrapper and not a fork. MapLibre does every pixel of the rendering, maplibre-gl stays a peer dependency your app owns, and map.map hands you the raw maplibregl.Map the moment our helpers stop being enough. Everything below is additive: delete the wrapper and you still have a working MapLibre app.
What does one npm install actually give you?
It gives you MapLibre plus five things you would otherwise write yourself: the MapMap style, the pmtiles:// protocol handler, typed routing against our gateway, a navigation camera and a position puck that does not teleport.
npm install @mapmap/maps maplibre-gl pmtiles
maplibre-gl and pmtiles are peer dependencies on purpose. Your app owns a single shared copy, because two MapLibre instances on one page break the WebGL context. The accepted ranges are maplibre-gl >= 4 < 6 and pmtiles >= 3 < 5.
| Export | What it is |
|---|---|
MapMapMap / createMap | The wrapper over maplibregl.Map. .map is the native instance |
RouteLayer | Typed routing (driving, walking, truck) that draws the line for you |
NavigationCamera | Course-up chase cam, one call per GPS fix |
PositionPuck | Current-position dot and heading arrow, interpolated between fixes |
GuidanceBanner, extractGuidance, speak | Visual and spoken turn-by-turn |
ThemeScheduler, setMapLanguage | Auto day/night and runtime label language |
AdrCheck | Typed client for POST /adr/check |
PlacesLayer | Clustered pins, popups and nearest-by-drive-time |
The package exports around 30 symbols in total, covering map, routing, ADR, guidance, styling and coordinate helpers, and a generated TypeDoc reference ships with the npm release.
One gotcha before anything else: the container needs an explicit height. A bare <div id="map"> renders a zero-pixel-tall canvas with no error and no map. Set height: 100vh (or any real height) before you debug anything else.
How do you point the map at a style?
Pass style a keyword, a URL or a Studio theme document, and the SDK compiles the rest.
import { createMap } from "@mapmap/maps";
import "maplibre-gl/dist/maplibre-gl.css";
const map = createMap({
container: "map",
apiKey: "snk_…",
// baseUrl defaults to https://api.mapmap.ai
style: "light", // "light" | "dark" | a Studio theme | a style URL
center: [-1.5, 52.6], // lon, lat
zoom: 6,
});
await map.whenReady();
The accepted shapes are "light", "dark", a Studio theme document, a full MapLibre StyleSpecification or a hosted style URL such as https://api.mapmap.ai/styles/midnight-fleet-9f3a2c@1.json. A theme document downloaded from Studio drops straight in, and its extra.nav block (route line, puck and banner design) is parsed onto map.navDesign and picked up automatically by RouteLayer and PositionPuck. How the tiles and the theme engine work underneath is covered in vector tiles, styles and the theme engine.
The apiKey you pass to createMap is reused by RouteLayer and AdrCheck, so you set it once.
Why did we wrap MapLibre instead of forking it?
Because a fork means we own every rendering bug for the rest of the product's life, and you lose the ability to walk away from us.
The tempting version of this SDK is a fork of MapLibre with our features patched into the renderer. We did not build that, for two reasons.
- We get upstream fixes for free. MapLibre ships renderer work we have no business duplicating. A fork means every upstream release becomes a merge, and merges get skipped when they are inconvenient. A wrapper means you bump
maplibre-glin your ownpackage.jsonand you are done. - You get an escape hatch.
map.mapis the realmaplibregl.Map. Every native call works:setPaintProperty,queryRenderedFeatures,addLayer, the lot.mapOptionspasses nativeMapOptionsstraight through.PlacesLayereven publishes its generated layer ids asstores.idsso you can style them with raw MapLibre when our options do not reach far enough. If you decide our wrapper is wrong for you, the exit is a refactor, not a rewrite.
The cost of this decision is real and we will name it: anything MapLibre cannot do, we cannot do either. The globe projection limit below is exactly that bill arriving.
What does NavigationCamera do that raw MapLibre does not?
It turns one GPS fix into a tilted, course-up chase cam with the puck anchored low on the screen, and it handles the four things that make hand-rolled cameras feel wrong.
import { NavigationCamera, PositionPuck } from "@mapmap/maps";
const camera = new NavigationCamera(map, { pitch: 60, zoom: 17 });
camera.attachPuck(new PositionPuck(map)); // one call now moves both
navigator.geolocation.watchPosition(({ coords }) =>
camera.follow({ lat: coords.latitude, lon: coords.longitude }, coords.heading ?? undefined),
);
| Option | Default | What it does |
|---|---|---|
pitch | 60 | Tilt in degrees, clamped 0 to 85 |
zoom | 17 | Follow zoom |
anchorY | 0.72 | Puck's vertical screen position, so the camera looks up the road |
easeMs | 900 | Ease per fix, capped by the observed fix interval |
autoRecentreMs | 6000 | Idle time before auto-recentre; 0 disables it |
Each follow() is an interruptible linear ease rather than a queued animation, so a fast fix cadence glides instead of stuttering. Any user drag, rotate, pitch or zoom switches camera.mode to "free" and the camera recentres itself after the idle timeout. camera.overview(route.geometry) fits the whole route top-down, camera.resume() returns to the chase cam.
PositionPuck interpolates between fixes by default, gliding along the shortest arc to each new fix over a duration matched to the observed fix interval, so the puck and camera arrive together instead of the puck jumping once a second. Pass { interpolate: false } to snap. The camera design is written up in full in the 3D navigation camera.
Precedence for pitch and zoom is option, then Studio design (extra.nav.camera), then built-in default.
Can you dim the travelled route and drop an arrow at the next turn?
Yes, in two calls, and both survive a light/dark style swap.
import { RouteLayer } from "@mapmap/maps";
const routes = new RouteLayer(map);
await routes.route(
{ lon: -0.1278, lat: 51.5074 }, // London
{ lon: -1.8904, lat: 52.4862 }, // Birmingham
{ profile: "truck", truck: { heightM: 4.0, weightT: 40, hazmat: true, tunnelCode: "C" } },
);
routes.setProgress(0.42); // 0-1 of the route length, travelled
routes.setManeuver([-1.8904, 52.4862], 135); // arrow at the next turn, bearing in degrees
// …later
routes.setProgress(0); // restore the plain line
routes.clearManeuver();
setProgress renders a line-gradient over the route source (built with lineMetrics), which is the vanishing route line effect. Pass progressColor in the layer options to change the dimmed colour. Derive the fraction from the guidance module's distance-remaining.
Can you change label language without new tiles?
Yes. MapMap tiles carry the OpenMapTiles multilingual name:* fields, so setMapLanguage rewrites the label expressions in place.
import { ThemeScheduler, setMapLanguage } from "@mapmap/maps";
setMapLanguage(map, "de"); // relabel in German; null restores the default
new ThemeScheduler({
lat: 51.5, lng: -0.13,
onLight: () => map.setStyle("light"),
onDark: () => map.setStyle("dark"),
});
setMapLanguage rewrites only layers whose text-field reads name properties, so road ref shields and house numbers are left alone. It validates the tag and throws on a malformed one, and it returns the ids of the layers it changed. It is a runtime layout-property rewrite, not a tile fetch, so it is instant and works offline.
ThemeScheduler flips light and dark at local sunrise and sunset using a dependency-free solar calculation, re-arming at each boundary, and it handles polar day and night. resolveTheme(date, lat, lng) is the one-shot form. Call dispose() to stop it. Territory packages ship paired light/dark styles, so this works offline too.
What does it not do?
Four honest limits, all of them things we would rather you heard from us than found at 2am.
NavigationCameradoes not support the globe projection.NavigationCamera.isSupported(map)returnsfalseunder globe or vertical-perspective, because the low-anchor offset maths and overview framing assume a mercator camera. Switch withmap.map.setProjection({ type: "mercator" })before navigating.- Voice de-duplication is your job. Each step's
voicearray is ordered by descending trigger distance. Speak each instruction once as its distance is crossed. We do not track that for you. - ESM only. The package ships
dist/index.jsanddist/index.d.ts, and needs Node >= 18 to build. There is no CommonJS build. - Pitch above 60 is allowed but not advised. It is clamped at 85 and raises MapLibre's
maxPitchfor you, but MapLibre marks it experimental and DOM-marker pucks flatten at extreme tilt.
Tiles and style writes are metered per request at the Standard price class, 0.05p per call, and the free tier is 50,000 included calls a month after you verify an email, with commercial use allowed. Style reads are public and unmetered.
The map renders a small MapMap wordmark bottom-right, which you can remove with logo: false. Attribution is separate and is not removable by configuration: every compiled style carries "© OpenStreetMap contributors © OpenMapTiles" on its tile source, because the data licence requires it.
Try it
Install the package, point it at a style and route something:
npm install @mapmap/maps maplibre-gl pmtiles
The maps documentation covers tiles, hosted styles, Studio and the theme document. The SDKs reference has the full @mapmap/maps surface, the error shapes and the licensing rules. Every call shown here works identically against the hosted gateway and against your own self-hosted deployment.
