UzMap docs

@uzmaps/engine

@uzmaps/engine is the JavaScript engine behind uzmaps.ndc.uz. It exports a UzMap class that wraps a MapLibre GL JS map with the UzMap cartography, an @uzmaps/api client, and overlays for markers, shapes, routes, a selected place and the user's location. It has no framework dependency; the React components in @uzmaps/ui are built on it. This page describes version 0.1.1 as it stands in the repository source; where the copy published to npm as 0.1.1 differs, it says so.

Installation#

bash
npm install @uzmaps/engine maplibre-gl

packages/engine/package.json lists maplibre-gl@^6.7.0 under dependencies (so does the published 0.1.1), alongside pmtiles, @uzmaps/api and @uzmaps/cartography. The package README describes maplibre-gl as a peer dependency; it is not declared as one. Add it to your own package.json anyway: the engine does not inject any CSS, so you import MapLibre's stylesheet yourself, and under package managers that do not hoist transitive dependencies (pnpm by default) that import only resolves when maplibre-gl is a direct dependency.

js
import { UzMap } from '@uzmaps/engine';
import 'maplibre-gl/dist/maplibre-gl.css';

Creating a map#

js
const map = new UzMap({
  container: 'map',                 // element id or HTMLElement
  serverUrl: 'https://uzmaps.ndc.uz',
  apiKey: 'YOUR_KEY',
  center: [69.2401, 41.2995],       // [lon, lat]
  zoom: 12,
  theme: 'auto',
  language: 'uz',
});

map.on('ready', () => {
  // basemap loaded; safe to fit, select, draw
});

The MapLibre map is created in the constructor. load and ready fire once, at the same moment, when MapLibre reports its load event.

UzMapOptions#

Every field is optional except container. Defaults are the values applied in map.ts.

Container and server#

OptionTypeDefaultNotes
containerHTMLElement | stringrequiredPassed to MapLibre unchanged.
serverUrlstring'' (same origin)Base URL for tiles, glyphs, sprites and the API. A relative value is resolved against the page origin, because MapLibre needs absolute sprite and glyph URLs.
apiKeystringSent as an X-API-Key header on API calls. Appended as ?key= to tile, glyph and sprite requests whose URL starts with serverUrl, because MapLibre issues some of those as image or worker loads where headers cannot be set. URLs on other hosts are left untouched so the key is not leaked.
tiles'server' | 'pmtiles''server''server' uses ${serverUrl}/tiles/tilejson.json. 'pmtiles' reads pmtiles://${serverUrl}/tiles/uzbekistan.pmtiles with HTTP range requests; the pmtiles protocol is registered with MapLibre the first time a UzMap is constructed.
sources{ mlBuildings?, places?, furniture? }, each boolean | stringall trueSupplementary tile sets layered over the basemap. true calls /api/status after load and enables the set when status.sources.buildings_ml, .places or .furniture is true, using ${serverUrl}/tiles/buildings-ml/tilejson.json, /tiles/places-overture/tilejson.json or /tiles/furniture/tilejson.json. A string is used as the TileJSON URL directly. false disables the set.

Camera#

OptionTypeDefaultNotes
centerLngLat[69.2797, 41.3111] (Tashkent)[lon, lat].
zoomnumber12
bearingnumber0
pitchnumber0With auto-tilt on, this becomes the user's tilt offset once the map has loaded: the offset is this pitch minus the curve's value at the load zoom (see 3D, tilt and terrain).
minZoomnumber3.5
maxZoomnumber20
maxBoundsBBox | null[50.5, 33.5, 78.5, 49.0]Uzbekistan with a wide margin. Pass null to remove the limit.
hashbooleanfalseMapLibre's URL-hash camera sync.

The maximum pitch is fixed at 70° and is not an option.

Appearance#

OptionTypeDefaultNotes
theme'light' | 'dark' | 'auto''auto''auto' follows prefers-color-scheme and switches live when the OS setting changes.
language'uz' | 'uz-Cyrl' | 'ru' | 'en''uz'Label language. Also the default language on map.api.
poisbooleanonOnly false hides POI icons and labels.
labelsbooleanonOnly false hides every basemap label.
density'full' | 'muted''full''muted' dims POIs and land use for overlay-heavy applications.
attributionControlbooleantrueShown as MapLibre's compact control.
localeRecord<string, string>MapLibre's localised UI strings.
transformStyle(style) => styleCalled with the generated style each time one is built: at construction and on every style rebuild (theme, language, terrain or supplementary-source change; a 3D toggle only rebuilds when the layer swap finds no building layers to swap).

3D and tilt#

OptionTypeDefaultNotes
buildings3dbooleanfalseStart with building extrusions on. They still only render while the camera is pitched.
autoTiltbooleantrueLet the pitch follow the zoom along a curve.
autoTiltFlatZoomnumber12.6Zoom at or below which the curve is flat.
autoTiltZoomnumber15.6Zoom at which the curve reaches autoTiltPitchSoft.
autoTiltPitchSoftnumber0Pitch (degrees) the curve holds from autoTiltZoom.
autoTiltPitchnumberDeprecated alias of autoTiltPitchSoft; read only when that is unset.
autoTiltPitchMaxnumber0Pitch the curve reaches at maxZoom.
tiltMaxnumber50Pitch that set3D(true) animates to.

With both pitch angles at their default of 0 the curve is flat everywhere, so the resting camera is overhead unless a project opts in.

Terrain#

OptionTypeDefaultNotes
terrainbooleanfalseAdd the raster-DEM terrain source.
terrainUrlstring${serverUrl}/terrain/tilejson.jsonTileJSON URL. The engine passes it as the raster-DEM source's url, so a tiles template does not work here even though the type's comment mentions one.

Interaction#

OptionTypeDefaultNotes
interactivebooleantrue
interactiveLayersstring[]see belowBasemap layers hit-tested for click, poi:click and poi:hover. Only layers present in the current style are queried.

The default interactive layers are uz-poi, uz-poi-places, uz-poi-transit, uz-aerodrome, uz-peak, uz-place-city, uz-place-town, uz-place-village, uz-place-suburb and uz-place-neighbourhood.

Not on the type#

map.ts also reads a route field (RouteOverlayOptions) from the options object and passes it to the RouteOverlay constructor, but UzMapOptions does not declare it. In TypeScript you need a cast to pass it; see Routes.

Properties#

PropertyTypeNotes
mlmaplibregl.MapThe underlying MapLibre map.
apiUzMapClientClient from @uzmaps/api, built with the same serverUrl, apiKey and language. setLanguage updates its language.
routeRouteOverlay
highlightHighlight
userLocationUserLocation
theme'light' | 'dark'The resolved theme; never 'auto'.
languageLanguage
terrainboolean
mode'2d' | '3d''3d' while extrusions are showing.
buildings3dbooleanSame as mode === '3d'.
isTiltedbooleanPitch is 4° or more.
trafficbooleanTraffic ribbon shown.
sources{ mlBuildings, places, furniture }, each string | nullResolved supplementary TileJSON URLs; null when absent or disabled.

Methods#

UzMap extends Emitter, so it also has on(event, fn) (returns an unsubscribe function), once, off, emit and clear. Listener exceptions are caught and logged, so one failing listener does not stop the others.

Camera#

MethodNotes
getCenter(): LngLat
getZoom(): number, getBearing(): number, getPitch(): number
getBounds(): BBox[west, south, east, north].
project(lngLat): { x, y } / unproject({ x, y }): LngLat
setPadding(padding, animate = true)padding is a partial { top, bottom, left, right } in px, merged with the current value. Eases over 300 ms, or sets immediately when animate is false. The stored padding is applied to every later flyTo, easeTo and fitBounds, so a bottom sheet or side panel is declared once.
flyTo(o: CameraOptions)Uses center, zoom, bearing, pitch and duration (default 1200 ms). padding, offset and animate on the options object are ignored; the stored padding is used.
easeTo(o: CameraOptions)Uses center, zoom, bearing, pitch, offset and duration (default 600 ms). padding and animate are ignored.
fitBounds(bbox, opts?)opts: padding (number or partial object, added to the stored padding), maxZoom (17.5), duration (800 ms), bearing, pitch. A zero-size bbox is padded out first so it does not slam to maxZoom. Padding is clamped so it never takes more than 70% of an axis, which is what makes fits silently fail. The camera is computed in plain Web Mercator rather than with MapLibre's cameraForBounds, because that returns undefined on a pitched camera and leaves the view untouched.
zoomIn(), zoomOut(), zoomBy(delta)350 ms ease.
rotateBy(deg)500 ms ease.
resetNorth()Bearing to 0 over 600 ms.
resetView()Back to the constructor's center and zoom, north up, pitch 0, over 1200 ms. Also clears the user's tilt offset.

CameraOptions is { center?, zoom?, bearing?, pitch?, duration?, padding?, offset?, animate? }; only the fields listed against each method are read.

js
map.setPadding({ bottom: 280 }, false);   // keep a bottom sheet clear of every framing
map.fitBounds([69.1, 41.2, 69.4, 41.4], { padding: 24, maxZoom: 15 });
map.flyTo({ center: [69.28, 41.31], zoom: 15 });
map.easeTo({ center: [69.28, 41.31], offset: [0, -60], duration: 500 });

Appearance and mode#

MethodNotes
setTheme(theme: 'light' | 'dark' | 'auto')Rebuilds the style; emits theme with the resolved name when it actually changes. 'auto' resolves against the OS setting now; later OS changes are only followed when the map was constructed with theme: 'auto' (the default), because the media-query listener is installed only in the constructor.
setLanguage(language)Rebuilds the style, sets api.language, emits language. No-op when unchanged.
setTerrain(on)Adds or removes the terrain source with a full style rebuild. See 3D, tilt and terrain.
setAutoTilt(on)Enable or disable the zoom-driven pitch at runtime.
set3D(on, { user? })Tilt into or out of 3D. The user flag is recorded but has no effect on behaviour in 0.1.1.
toggle3D(): booleanToggles and returns the new state.
setTraffic(on)Show or hide the traffic ribbon.
setTrafficProvider(provider | null)Attach or detach the traffic data source.
setTrafficFeatures(features)Push congestion segments directly.

Places and routes#

MethodNotes
flyToPlace(place, { maxZoom = 16.5, duration = 900 })place is anything with lat and lon (a SearchResult works). When place.bbox exists and is not a point, it is framed with fitBounds; otherwise the camera flies to the point at max(currentZoom, maxZoom).
selectPlace(place | null, { fly = true, geometry? })Places the highlight pin at the place (tinted with place.color when given) and draws geometry (a GeoJSON Feature) as an outline. Flies there unless fly: false. null clears the highlight.
showRoutes(routes, waypoints = [], { selectedId?, fit = true, fitPadding = 60 })Draws the routes and frames them (maxZoom 16). fitPadding is separate from the stored padding because a route is framed against the area inside a sheet or panel.
clearRoutes()

Overlays#

MethodNotes
addMarkers(id, features, opts?): MarkerLayerCreates a marker layer. If id already exists, its data is replaced with setData and opts is ignored: options are fixed at creation, so call removeMarkers first to change them.
removeMarkers(id)
addShape(id, data, style?): ShapeLayerAlways removes any existing layer with that id first, so a new style takes effect.
removeShape(id)

Lifecycle#

MethodNotes
destroy()Cancels timers and animation frames, removes every marker and shape layer, stops user location, and calls ml.remove().

Events#

Payload types are from UzMapEvents in types.ts; the "fires when" column is from map.ts.

EventPayloadFires when
loadvoidMapLibre's load; once.
readyvoidImmediately after load; once.
stylereadyvoidAfter every style load: the first, and each rebuild for theme, language, terrain or sources (a 3D toggle only when it has to fall back to a rebuild).
idlevoidMapLibre's idle.
errorErrorA MapLibre error event carrying an error (also logged as [uzmap]), or a failed traffic provider call.
move{ center: LngLat; zoom; bearing; pitch }Every camera frame.
moveend{ center; zoom; bearing; pitch; bounds: BBox }Camera stopped.
zoomnumberZoom changed.
click{ lngLat: LngLat; point: { x, y }; feature: BasemapFeature | null; originalEvent: MouseEvent | TouchEvent }Every click. feature is the interactive basemap feature under the pointer, if any.
poi:clickBasemapFeatureAfter click, only when feature is not null.
poi:hoverBasemapFeature | nullThe hovered basemap feature changed (compared by properties.name and presence); null on leaving. Hit-testing is throttled to one queryRenderedFeatures per animation frame.
contextmenu{ lngLat: LngLat; point: { x, y } }Right-click; the browser menu is suppressed.
marker:click{ layer: string; feature: MarkerFeature; lngLat: LngLat }A marker in any layer was clicked; layer is the id passed to addMarkers. Forwarded from the MarkerLayer (see below).
marker:hover{ layer: string; feature: MarkerFeature | null }The pointer entered or left a marker. Forwarded.
cluster:click{ layer: string; count: number; lngLat: LngLat; expansionZoom: number }A cluster bubble was clicked. Forwarded.
route:selectstringThe selected route changed via a click on an alternative or map.route.select. Forwarded from map.route.
theme'light' | 'dark'The resolved theme changed, via setTheme or the OS under 'auto'.
languageLanguagesetLanguage changed it.
tiltbooleanPitch crossed 4°.
trafficbooleansetTraffic toggled it.
terrainunavailableundefinedTerrain was switched off after a render failure.
sources{ mlBuildings: string | null; places: string | null }Supplementary tile sets were resolved and applied. furniture is not in the payload; read map.sources.

BasemapFeature:

FieldType
layerstring — the basemap layer id that was hit
name, class, subclass, iconstring | undefined
lngLatLngLat — the feature's own coordinate for points, otherwise the click position
point{ x, y }
propertiesRecord<string, unknown> — the raw tile properties
js
const off = map.on('click', ({ feature }) => {
  if (feature) return;          // poi:click handles it
  map.selectPlace(null);
});

map.on('poi:click', async (f) => {
  const { result } = await map.api.lookup(f.lngLat[1], f.lngLat[0], f.name ?? '');
  if (result) map.selectPlace(result, { fly: false });
});

off();

Forwarded from the overlays#

In the repository source, UzMap forwards marker:click, marker:hover and cluster:click from every MarkerLayer created with addMarkers (adding the layer id to the payload), and route:select from map.route. The forwarders are released in removeMarkers and destroy. The overlay objects still emit their own events when you only care about one layer.

The package published to npm as 0.1.1 predates this: its dist/map.js contains no forwarding, so on that copy map.on('marker:click', …) type-checks and never fires. Listen on the overlay objects there.

On the mapOn the overlay
marker:click, marker:hover, cluster:clicklayer.on('click' | 'hover' | 'cluster:click', …) on the MarkerLayer returned by addMarkers
route:selectmap.route.on('select', id => …)

Declared but never emitted#

UzMapEvents also declares userlocation and userlocation:error. UzMap does not emit either, in the source or on npm: map.on('userlocation', …) type-checks and never fires. Use map.userLocation.on('update' | 'error' | 'stop', …) instead.

Markers#

Markers are managed as named layers, not one at a time. Each layer is one GeoJSON source rendered with symbol layers, so thousands of markers are cheap and collision is resolved across the whole set. Pin images are drawn on a canvas at the device pixel ratio and registered with MapLibre on demand.

A MarkerFeature is a GeoJSON Feature<Point, MarkerFeatureProps>:

PropertyTypeDefaultNotes
idstring | numberrequiredUsed for selection and hover state.
colorstringthe layer's colorMust be a hex colour such as #e11d48. The pin image id is built by stripping # and parsed back by prepending it, so a named colour or rgb() value comes out as an invalid colour and the pin renders black.
labelstringShown under the pin from labelMinZoom while labels is on.
prioritynumber0Higher wins collisions.
iconstringDeclared on the type but not read by MarkerLayer in 0.1.1; nothing is drawn inside the pin.
anything elseKept and returned in events. Keys beginning with __ are reserved.

MarkerLayerOptions, with the defaults from markers.ts:

OptionDefaultNotes
color'#2F7CF6'Default pin colour (hex).
shape'pin''pin' is a teardrop anchored at its point; 'dot' is a circle anchored at its centre.
size30CSS px. The selected variant is 1.25×.
labelstrue
labelMinZoom11
allowOverlapfalsetrue disables collision hiding between markers.
belowLabelsfalsetrue inserts the layer below basemap labels. The comment in types.ts says pins default to true; the code default is false.
clusterfalseGeoJSON clustering.
clusterRadius48
clusterMaxZoom15
clusterColorsame as color

MarkerLayer:

MemberNotes
id, sourceIdsourceId is uz-markers-<id>. Layer ids are <sourceId>-base, -selected, -clusters and -counts.
optionsThe resolved options.
setData(features)Replace all features.
getData(): MarkerFeature[]Current features without the reserved __ keys.
select(id | null) / selectedShow one marker in the larger selected style, drawn above the basemap labels even when belowLabels is set, and never hidden by collision.
setVisible(visible)
mount() / unmount()Re-create or remove the source and layers; data is kept. mount() is called automatically on every style load; unmount() is not called by UzMap.
remove()Unmount, drop listeners and mark the layer destroyed.

Events on a MarkerLayer:

EventPayload
click{ feature: MarkerFeature; lngLat: LngLat }
hover{ feature: MarkerFeature | null }
cluster:click{ count: number; lngLat: LngLat; expansionZoom: number } — the layer also eases to min(expansionZoom + 0.3, 18) over 500 ms.
js
const offices = map.addMarkers('offices', [
  {
    type: 'Feature',
    geometry: { type: 'Point', coordinates: [69.24, 41.29] },
    properties: { id: 'hq', label: 'Head office', color: '#e11d48', priority: 10 },
  },
  {
    type: 'Feature',
    geometry: { type: 'Point', coordinates: [69.28, 41.31] },
    properties: { id: 'branch-1', label: 'Chilonzor' },
  },
], { cluster: true, clusterRadius: 60 });

offices.on('click', ({ feature, lngLat }) => {
  offices.select(feature.properties.id);
  map.easeTo({ center: lngLat, duration: 400 });
});

map.addMarkers('offices', newFeatures);   // replaces the data; options unchanged
map.removeMarkers('offices');

Shapes#

addShape(id, data, style) draws polygons and lines: zones, regions, boundaries, tracks. data (ShapeData) is a FeatureCollection, a single Feature or a bare Geometry. Polygons get a fill layer and a line layer; lines get the line layer. A feature can override its colour with properties.fillColor, properties.lineColor or properties.color. Any CSS colour works here, because these values go straight into paint expressions.

ShapeStyle, with the defaults from shapes.ts:

OptionDefaultNotes
color'#2F7CF6'Fallback for both fill and line.
fillColorcolor
fillOpacity0.18Raised by 0.1 while a feature's hover feature-state is true. The layer sets no hover state itself; set it through map.ml.setFeatureState if you want the effect.
lineColorcolor
lineWidth2
lineOpacity0.9
lineDashMapLibre line-dasharray.
belowRoadsfalsetrue inserts the layers under roads, for delivery zones and the like.
labelPropertyAdds a symbol layer showing that property at each feature.
labelColor'#1F2226'
outlineDeclared on the type; not read in 0.1.1.

ShapeLayer:

MemberNotes
id, sourceIdsourceId is uz-shapes-<id>; layers are <sourceId>-fill, -line and -label.
layersThe current layer ids.
setData(data)
setStyle(style)Merges into the current style and re-creates the layers.
setVisible(visible)
mount() / unmount() / remove()As for MarkerLayer.

ShapeLayer emits no events.

js
const zones = map.addShape('delivery-zones', {
  type: 'FeatureCollection',
  features: [{
    type: 'Feature',
    properties: { id: 'north', name: 'Shimoliy zona', fillColor: '#16a34a' },
    geometry: { type: 'Polygon', coordinates: [[[69.20, 41.33], [69.30, 41.33], [69.30, 41.38], [69.20, 41.38], [69.20, 41.33]]] },
  }],
}, { color: '#E8712B', fillOpacity: 0.15, belowRoads: true, labelProperty: 'name' });

zones.setStyle({ lineDash: [2, 2] });
zones.setVisible(false);
map.removeShape('delivery-zones');

Routes#

showRoutes takes the routes array from map.api.route() (each Route has id, mode, geometry as [lon, lat][], bbox, recommended, legs, distance, duration and summary). The selected route is selectedId if given, otherwise the first route with recommended: true, otherwise the first route. Alternatives are drawn in the theme's alternative colour with a wide invisible hit line, so clicking one selects it and emits select on map.route. Origin, via and destination pins use the sprite icons route-start, route-via and route-end; when fewer than two waypoints are passed, the selected route's first and last coordinates are used. A selected route in foot mode gets a dotted white overlay. Drawing in is animated over 650 ms with a moving line-gradient stop.

map.route (RouteOverlay):

MemberNotes
show(routes, waypoints = [], selectedId?)Draw without framing. showRoutes calls this then fits.
select(id)Ignored for unknown ids or the current selection; emits select.
selectedThe selected Route, or null.
setStep(lngLat | null)Highlight a manoeuvre point with a white circle, or clear it.
bboxUnion of every shown route's bbox, or null.
clearRoutes()
setTheme(theme), mount(), unmount(), remove()Managed by UzMap; rarely needed.

Event: select with the route id as a string.

js
const points = [[69.24, 41.29], [69.28, 41.31]];
const { routes } = await map.api.route({ points, mode: 'car' });

map.showRoutes(routes, points, { fitPadding: { top: 40, bottom: 240, left: 40, right: 40 } });
map.route.on('select', (id) => console.log('selected', id));
map.route.setStep([69.26, 41.30]);
map.route.setStep(null);
map.clearRoutes();

RouteOverlayOptions is read from the undeclared route constructor option. Only animate (default true), endpoints (default true) and colors are used. colors has primary, primaryCasing, alt and altCasing, each defaulting to the theme's route tokens (light: #2F7CF6, #1E5DC4, #9DB6D8, #7F98BB; dark: #4D8DFF, #1E4FA8, #5D6B85, #465267). fit, fitPadding and dotted are declared but not read; use the fit and fitPadding arguments of showRoutes.

ts
const map = new UzMap({
  container: 'map',
  route: { animate: false, colors: { primary: '#0f766e' } },
} as UzMapOptions & { route: RouteOverlayOptions });

Selected place#

selectPlace and flyToPlace drive map.highlight (Highlight): a pin of size 34 in the selected style (which draws at 1.25×) with a soft pulse ring, plus a fill and cased line for a street or area geometry. The colour is the theme's selection token (#2F7CF6 light, #4D8DFF dark) unless a hex colour is given.

MemberNotes
set(lngLat | null, geometry?, color?)Place the pin, optionally with a GeoJSON Feature outline and a hex tint.
setGeometry(feature | null)Change only the outline.
clear()
js
const { results } = await map.api.search('Chorsu');
const place = results[0];
const geometry = await map.api.geometry(place.id);
map.selectPlace(place, { geometry });

User location#

map.userLocation (UserLocation) draws a blue dot with an accuracy halo sized in metres and, when the fix carries a heading, a direction cone. Fixes are interpolated over 500 ms so the dot glides rather than jumps. Nothing starts automatically; destroy() stops it.

MemberNotes
start(options?)Calls navigator.geolocation.watchPosition. options is a PositionOptions, default { enableHighAccuracy: true, maximumAge: 5000, timeout: 15000 }. No-op when geolocation is unavailable or already watching.
stop()Clears the watch and the dot; emits stop.
position{ lngLat: LngLat; accuracy: number; heading: number | null } | null
activeWhether a watch is running.

Events:

EventPayload
update{ lngLat: LngLat; accuracy: number; heading: number | null }
errorGeolocationPositionError
stopvoid
js
map.userLocation.on('update', ({ lngLat, accuracy }) => console.log(lngLat, accuracy));
map.userLocation.on('error', (err) => console.warn(err.code, err.message));
map.userLocation.start();

const pos = map.userLocation.position;
if (pos) map.easeTo({ center: pos.lngLat, zoom: 16 });

3D, tilt and terrain#

Extrusions follow the pitch#

Building extrusions are tied to the live camera pitch on every move event, whether or not auto-tilt is on: they switch on at 7° and off below 4°. MapLibre's camera is a perspective camera, so at pitch 0 a building away from the screen centre is still viewed obliquely and its walls show as a hard slab hanging off the footprint. A flat camera therefore always means flat footprints, and 3D expresses itself by tilting. In 2D the cartography draws an offset wall under each footprint instead, which reads as a subtle 2.5D volume.

set3D(true) sets the user's tilt offset so the target at the current zoom is tiltMax (50°) and eases the pitch there over 800 ms; the extrusions come on as the pitch crosses the threshold. set3D(false) sets the offset so the target at the current zoom is 0 and eases the pitch back to 0. The layer swap toggles visibility between the cartography's flat and extruded building layers without rebuilding the style, falling back to a rebuild only if none of those layers exists. With autoTilt: false, set3D swaps the layers without animating; set a pitch yourself, or the next camera move will flatten them again.

buildings3d: true in the constructor opens the map at the pitch the curve gives for the starting zoom (flat, with default angles), so it starts as footprints until the camera is tilted.

js
map.set3D(true);
map.on('tilt', (tilted) => button.classList.toggle('active', tilted));
console.log(map.mode);        // '3d' once the pitch has crossed 7°
map.toggle3D();

The auto-tilt curve#

With autoTilt on, the target pitch is a function of zoom: flat at or below autoTiltFlatZoom, smoothstepped up to autoTiltPitchSoft at autoTiltZoom, then on to autoTiltPitchMax at maxZoom. A deliberate pitch gesture (touch pitch, right-drag) is kept as an offset, clamped to ±70°, so zooming modulates perspective around the user's tilt rather than fighting it. The follower eases the pitch towards the target on its own animation-frame loop rather than with easeTo, because MapLibre camera animations are exclusive and it has to run during a wheel gesture without cancelling it.

Consequence: with auto-tilt on, a pitch passed to flyTo, easeTo or fitBounds holds only while that animation runs (the follower stays out of the way for its duration), and the pitch then eases back to the curve plus the user's offset. To hold a pitch, use set3D(true), pass pitch in the constructor (it becomes the offset when the map loads), or set autoTilt: false.

Terrain#

terrain: true or setTerrain(true) adds a raster-DEM source from terrainUrl. Adding or removing that source cannot be diffed by MapLibre, so setTerrain asks for a full style rebuild; overlays, camera and runtime images survive it.

After setTerrain(true) the engine listens on window for 8 s for an error event whose message or stack matches /terrain|shaderPrelude/i. Terrain renders inside MapLibre's own animation frame, and an exception there escapes the callback and stops the render loop for the life of the page with no way to catch it from outside. When the watcher fires, terrain is switched off, the style rebuilt, a repaint forced, a warning logged, and terrainunavailable emitted. The watcher is only installed by setTerrain(true), not by terrain: true in the constructor.

js
map.on('terrainunavailable', () => showNotice('Terrain is not available on this device'));
map.setTerrain(true);

Traffic#

There is no open real-time traffic feed for Uzbekistan, so the engine draws whatever an application supplies. setTraffic(true) shows the cartography's uz-traffic-casing and uz-traffic layers and fetches immediately; setTraffic(false) stops the timer and clears the data, so a hidden ribbon costs nothing.

A TrafficProvider is (req: { bbox: BBox; zoom: number }) => TrafficResponse | Promise<TrafficResponse>, where TrafficResponse is { features: TrafficFeature[]; refreshMs?: number }. The engine refreshes every refreshMs (default 60 000 ms, clamped to at least 5 000). A response that arrives after a newer request was issued is discarded. If the provider throws, the last good ribbon is kept, error is emitted, and the next attempt waits 120 s.

A TrafficFeature is a LineString feature whose properties.congestion is one of 'free' | 'moderate' | 'heavy' | 'severe' | 'closed', with optional speed and name.

js
map.setTrafficProvider(async ({ bbox, zoom }) => {
  const res = await fetch(`/traffic?bbox=${bbox.join(',')}&z=${zoom}`);
  return { features: await res.json(), refreshMs: 30_000 };
});
map.setTraffic(true);

// or push from your own loop
map.setTrafficFeatures(features);

Theme and language#

setTheme and setLanguage rebuild the style with MapLibre's diffing. The rebuild carries across every source that is not part of the generated style, together with the layers on those sources, inserting them at the overlay anchors by layer type; marker and shape layers are additionally re-mounted on style.load, district label points are re-applied, traffic is re-fetched, and runtime pin, cluster and chip images are regenerated. That is why overlays, camera and selection survive a theme or language change.

theme: 'auto' follows prefers-color-scheme and applies changes live; map.theme is always the resolved 'light' or 'dark'.

Using MapLibre directly#

map.ml is the maplibregl.Map. Anything the wrapper does not cover can be done there, with a few things worth knowing.

Fixed MapLibre settings. maxPitch: 70, dragRotate, touchPitch and pitchWithRotate on, cooperativeGestures off, fadeDuration: 250, antialiased high-performance canvas, no MapLibre logo.

Layer anchors. The style contains three empty anchor layers: uz-anchor-overlay-fill, uz-anchor-overlay-line and uz-anchor-overlay-symbol. Pass one as beforeId to map.ml.addLayer to slot your layer under labels, under roads, or on top. They are exported as anchors from @uzmaps/cartography.

Surviving restyles. A layer you add on your own source (one not in the generated style) is carried across every theme, language, 3D, terrain and source rebuild, inserted at the anchor matching its type (symbol, line, or fill for everything else). A layer added on a basemap source is not carried across; re-add it on styleready.

Reserved ids. The engine uses the uz- prefix for its own sources and layers: uz-markers-<id>, uz-shapes-<id>, uz-route, uz-route-points, uz-route-step, uz-highlight-pin, uz-highlight-geom, uz-user-location, and the layers built on them (uz-route-line, uz-route-alt-hit, uz-highlight-pin, uz-user-dot, and so on). Avoid the prefix for your own ids.

Runtime images. Pin, cluster and label-chip images are generated on MapLibre's styleimagemissing from their id alone, so your own symbol layer can reference one without registering it. Ids are uz-pin:<pin|dot>:<size>:<hex>[:sel][:hollow], uz-cluster:<hex> and uz-chip:<bg hex>:<border hex>; pinImageId builds the first form.

js
import { pinImageId } from '@uzmaps/engine';

map.ml.addSource('depots', { type: 'geojson', data: depots });
map.ml.addLayer({
  id: 'depots',
  type: 'symbol',
  source: 'depots',
  layout: {
    'icon-image': pinImageId({ color: '#e11d48', size: 30, shape: 'pin' }),
    'icon-anchor': 'bottom',
  },
}, 'uz-anchor-overlay-symbol');

Camera calls on ml. map.ml.flyTo and friends bypass the wrapper's stored padding and its tilt suppression. With auto-tilt on, the follower re-evaluates after any zoom and eases the pitch back to its curve; use the UzMap camera methods when that matters.

Other exports#

Geo helpers from geo.ts:

ExportNotes
UZBEKISTAN_BBOX[55.9, 37.1, 73.2, 45.7]
UZBEKISTAN_MAX_BOUNDS[50.5, 33.5, 78.5, 49.0] — the default maxBounds
TASHKENT[69.2797, 41.3111] — the default center
bboxOfCoords(coords: LngLat[]): BBox
bboxUnion(a, b): BBox
bboxIsPoint(b): boolean
bboxPad(b, frac): BBoxPads by a fraction of each side, with a small minimum.
distanceMeters(a, b): numberHaversine.
bearing(a, b): numberDegrees, 0–360.
pointAlong(line, t): { point: LngLat; bearing: number }Position at fraction t of the line's length.
metersPerPixel(lat, zoom): numberWeb Mercator, 512 px tiles.

Also exported: the easing functions easeOutCubic and easeInOutQuint; the Emitter class; pinImageId and drawPin; the overlay classes MarkerLayer, ShapeLayer, RouteOverlay, Highlight and UserLocation; and, re-exported from @uzmaps/api, UzMapClient, createClient, formatDistance, formatDuration and every API type. The engine's own types (LngLat, BBox, Theme, CameraOptions, UzMapOptions, UzMapEvents, BasemapFeature, MarkerFeature, MarkerFeatureProps, MarkerLayerOptions, ShapeData, ShapeStyle, RouteOverlayOptions, TrafficProvider, TrafficRequest, TrafficResponse, TrafficFeature, CongestionLevel) are exported from the package root.

Errors in the published 0.1.1 README#

The README shipped inside @uzmaps/engine@0.1.1 on npm contains examples that do not match the code it shipped with. The repository copy and examples/engine.ts have been corrected; the npm copy will be right from the next publish.

README saysActual
map.on('marker:click', …), map.on('route:select', …)Not emitted by the engine shipped in 0.1.1 (its dist/map.js has no forwarding). The repository source now forwards them from the overlays, so these examples are correct against the next publish. On the npm copy use layer.on('click', …) and map.route.on('select', …).
const { routes } = await api.route(…) with no api definedmap.api.route(…).
language: 'uz' // 'uz' | 'ru' | 'en'Language is 'uz' | 'uz-Cyrl' | 'ru' | 'en'.
properties: { …, icon: 'poi-office' } on a markericon is not read; no icon is drawn.
"maplibre-gl is a peer of this package"Declared under dependencies, not peerDependencies, in both the source tree and the published package.

Map data © OpenStreetMap contributors, licensed under the ODbL. © 2026 National Development Community.