UzMap docs

@uzmaps/ui and @uzmaps/cartography

Two packages sit on top of @uzmaps/engine and @uzmaps/api:

PackageVersionWhat it is
@uzmaps/ui0.1.1React components (map host, search box, result list, place card, route panel, map controls, bottom sheet), a stylesheet, icons, translations and hooks.
@uzmaps/cartography0.1.0buildStyle(), which generates the MapLibre style; the light and dark token sets; the POI category, icon and sprite tables; nameExpr().

Every symbol on this page was confirmed in the file named next to it. Anything that could not be confirmed is listed under Unverified at the end rather than guessed at.


@uzmaps/ui#

Install#

bash
npm install @uzmaps/ui @uzmaps/engine @uzmaps/api react react-dom

From packages/ui/package.json: dependencies are @uzmaps/engine ^0.1.1, @uzmaps/api ^0.1.0 and lucide-react; peers are react >=18 and react-dom >=18, with @types/react >=18 as an optional peer. The package is ESM only ("type": "module", a single import entry in exports).

The stylesheet#

js
import '@uzmaps/ui/styles.css';

package.json maps "./styles.css" to ./dist/styles.css; scripts/postbuild.mjs copies every .css file under src/ into dist/ after tsc, because tsc emits nothing but JavaScript and declarations. Nothing injects the stylesheet at import time, so you control its order against your own CSS.

Facts about packages/ui/src/styles.css that affect how you lay out a page:

SelectorWhat it does
.uz-appScope for the design system: position: relative; width: 100%; height: 100%; overflow: hidden, the font stack, and resets for button, input, a, summary, scrollbars and focus rings. Put it on the element that wraps MapView.
.uz-mapSet by MapView on its root: position: absolute; inset: 0. The map fills whichever positioned ancestor contains it — usually .uz-app.
[data-uz-theme='dark']Redeclares the colour tokens (--uz-bg*, --uz-fg*, --uz-border*, --uz-accent*, --uz-ring, --uz-success*, --uz-warning*, --uz-danger*, --uz-shadow-1…3, --uz-scrim, --uz-glass, --uz-skeleton) for the dark palette and sets color-scheme: dark. There is no prefers-color-scheme rule in the file, and nothing in @uzmaps/ui sets this attribute: the host application does.
@media (max-width: 720px).uz-panel and .uz-scale are hidden.
@media (min-width: 721px).uz-topbar and .uz-sheet (the BottomSheet root) are hidden.
@media (prefers-reduced-motion: reduce)Animations and transitions inside .uz-app are cut to 0.01ms.

The tokens (--uz-bg, --uz-fg, --uz-accent, --uz-s-1--uz-s-8, --uz-r-sm--uz-r-full, --uz-dur-fast/--uz-dur/--uz-dur-slow, --uz-panel-w: 400px, --uz-control: 40px, --uz-topbar-h: 48px, --uz-safe-top, --uz-safe-bottom) are all declared on :root. Only the colour and shadow tokens are redeclared under [data-uz-theme='dark']; spacing, radii, motion and layout tokens are the same in both themes. --uz-panel-w is overridden to 100% at max-width: 720px. Layout classes used by the components but not owned by any one of them include .uz-panel (desktop left column, 400px wide), .uz-panel-card, .uz-panel-scroll, .uz-surface, .uz-topbar, .uz-toast and .uz-banner.

Controlled components#

The list, search, chips, route panel and bottom sheet hold no data of their own. They take state as props and raise callbacks; they never call the server. You keep the query, the results, the selected route and the sheet position in your own state and pass them down. This is why SearchBox has no api prop and ResultList has no onSearch: debouncing, cancellation and ranking belong to the application (the useSearch hook below does that work if you want it done for you).

Three components are not purely controlled, and the difference matters:

ComponentWhat it owns
MapViewCreates and destroys the UzMap instance and provides it through context.
MapControls, ScaleBarRead camera state from the map via useUzMap() and call camera methods on it directly (zoomIn, rotateBy, toggle3D, …). Theme, language and terrain remain controlled through props.
PlaceCardTakes an api: UzMapClient and fetches place details and the photo itself.

Everything exported#

From packages/ui/src/index.ts:

ExportKind
MapView, useUzMap, MapViewPropscomponent, hook, type
SearchBox, ResultList, ResultIcon, EmptyState, CategoryChips, highlight, SearchBoxProps, ResultListPropscomponents, function, types
PlaceCard, OpenStatus, PlaceCardPropscomponents, type
RoutePanel, RoutePanelProps, RouteEndpointcomponent, types
MapControls, ScaleBar, MapControlsPropscomponents, type
BottomSheet, BottomSheetProps, SheetSnapcomponent, types
PoiIcon, poiIcons, maneuverIcon, uicomponent, table, function, table
t, makeT, languageNamesfunctions, table
useDebouncedValue, useMediaQuery, useIsMobile, useSearch, useRecentSearches, useLocalStorage, useToast, useListNav, useStableCallbackhooks

Exported from a module but not from the package index, so not reachable from @uzmaps/ui: useMap3D and DataSourceBadge, CopyKind, IconComponent.

MapView and useUzMap#

packages/ui/src/MapView.tsx.

ts
interface MapViewProps extends Omit<UzMapOptions, 'container'> {
  className?: string;
  style?: React.CSSProperties;
  children?: ReactNode;
  onReady?: (map: UzMap) => void;
}
function useUzMap(): UzMap | null

MapView renders <div class="uz-map {className}">, creates new UzMap({ container, ...options }) in a mount effect, and calls map.destroy() on unmount. Children are rendered only once the instance exists (map ? <Ctx.Provider>… : null), so inside a MapView useUzMap() returns the instance; it returns null only when called outside one. The instance existing does not mean the style has loaded: onReady is wired to map.once('load', …) for that.

Options are read once at mount (they are captured in a ref). After mount only four props are watched, each applied through the engine's setter:

Prop changeEngine call
thememap.setTheme(theme)
languagemap.setLanguage(language)
buildings3dmap.set3D(buildings3d)
terrainmap.setTerrain(terrain)

Changing center, zoom, serverUrl, apiKey or any other option after mount has no effect. For debugging, the instance is also assigned to window.__uzmap.

The inherited options, from UzMapOptions in packages/engine/src/types.ts (defaults are those stated in that file's doc comments):

OptionTypeNotes
serverUrlstringBase URL for tiles, glyphs, sprites and the API. Empty string means same origin.
theme'light' | 'dark' | 'auto'Theme = ThemeName | 'auto'.
language'uz' | 'uz-Cyrl' | 'ru' | 'en'
center[lon, lat]LngLat.
zoom, bearing, pitch, minZoom, maxZoomnumber
maxBounds[w, s, e, n] | nullDefaults to Uzbekistan with a margin; null disables.
hashbooleanSync the camera with the URL hash.
buildings3dboolean
autoTiltbooleanPitch follows zoom. The curve is flat by default.
autoTiltFlatZoomnumberDefault 12.6.
autoTiltZoomnumberDefault 15.6.
autoTiltPitchSoftnumberDefault 0.
autoTiltPitchnumberDeprecated alias of autoTiltPitchSoft.
autoTiltPitchMaxnumberDefault 0.
tiltMaxnumberPitch the 3D button animates to. Default 50.
terrainboolean
terrainUrlstringDefault ${serverUrl}/terrain/tilejson.json when terrain is true.
pois, labelsbooleanPassed through to the style.
density'full' | 'muted'StyleOptions['density'].
tiles'server' | 'pmtiles'Default 'server'.
sources{ mlBuildings?, places?, furniture?: boolean | string }true auto-detects from /api/status, a string is an explicit TileJSON URL, false disables.
apiKeystringSent as a header on API calls and as ?key= on tile, glyph and sprite URLs.
interactive, attributionControlboolean
interactiveLayersstring[]Which basemap layers emit poi:click / poi:hover.
localeRecord<string, string>MapLibre UI strings.
transformStyle(style: StyleSpecification) => StyleSpecificationCalled once with the generated style before it is applied.

packages/ui/src/SearchBox.tsx.

PropTypeDefaultNotes
valuestringrequired
onChange(v: string) => voidrequiredAlso called with '' by the clear button, which then refocuses the input.
onSubmit() => voidEnter.
onFocus, onBlur() => void
onKeyDown(e: KeyboardEvent<HTMLInputElement>) => voidRuns before the built-in handling. Call e.preventDefault() to suppress Enter → submit or Escape → blur for that key.
loadingbooleanShows .uz-spinner.
languageLanguagerequiredPlaceholder and aria-label come from t().
placeholderstringt(language, 'searchPlaceholder')
autoFocusbooleanFocuses the input in an effect.
leadReactNode<ui.Search />Leading slot, e.g. a back button.
trailReactNodeTrailing slot, rendered after a divider.
hintReactNodeRendered only while unfocused and empty. The stylesheet shows .uz-search-hint only at min-width: 721px with hover: hover and a fine pointer.
inputRefRefObject<HTMLInputElement | null>internal ref
listIdstringWhen set, the input gets role="combobox", aria-controls, aria-expanded="true" and aria-autocomplete="list". Pass the same string as ResultList's id.
activeIndexnumberWith listId, sets aria-activedescendant to ${listId}-opt-${activeIndex}.

The root is <div class="uz-search-box" role="search">, with is-focused while the input has focus. The input has autoComplete, autoCorrect, autoCapitalize and spellCheck off and enterKeyHint="search".

ResultList, ResultIcon, EmptyState, CategoryChips, highlight#

All in packages/ui/src/SearchBox.tsx.

ResultList

PropTypeDefaultNotes
resultsSearchResult[]requiredRow key is ${kind}-${id}.
querystring''Passed to highlight() for the title.
languageLanguagerequired
activenumber-1Index of the highlighted row; it is scrolled into view when it changes.
onHover(i: number) => voidMouse enter, and arrow-key focus moves.
onSelect(r: SearchResult) => voidrequiredClick, Enter or Space on a row.
onDirections(r: SearchResult) => voidAdds a directions button per row.
onEscape() => voidEscape while a row has focus.
titlestringSection heading; also the listbox aria-label.
titleActionReactNodeRendered at the right of the title.
showDistancebooleantrueShows formatDistance(r.distance, language) when r.distance is set.
onRemove(r: SearchResult) => voidAdds a remove button per row (used for recent searches).
idstringListbox id; each row gets ${id}-opt-${i} for aria-activedescendant wiring with SearchBox.

Rows are role="option" with tabIndex={0}; ArrowUp/ArrowDown move focus between rows.

ResultIcon{ r: Pick<SearchResult, 'icon' | 'group' | 'kind'>; size?: number }. Renders PoiIcon in a coloured square. Kinds street, address and place get the neutral grey style; otherwise the background is groupById[r.group].color, falling back to #4B5563.

EmptyState{ title: string; hint?: ReactNode; icon?: ReactNode; detail?: string; action?: ReactNode; tone?: 'error' }. tone: 'error' adds is-error and role="alert"; detail is rendered in monospace and clipped.

CategoryChips{ language: Language; active?: string | null; onSelect: (groupId: string | null) => void; groups?: string[] }. Renders one chip per group id, coloured and labelled from groupById in @uzmaps/cartography. Clicking the active chip calls onSelect(null). Default groups: food, shopping, health, transport, culture, lodging, finance, auto, leisure, education. Labels for uz-Cyrl fall back to the Russian label, since the group table carries uz, ru and en only.

highlight(text, query) — splits query on whitespace and commas, drops tokens of one character, and wraps the matching prefix of each word of text in <mark>. Returns a ReactNode.

PlaceCard and OpenStatus#

packages/ui/src/PlaceCard.tsx.

PropTypeNotes
apiUzMapClientUsed for api.place(), api.photo() and api.photoUrl().
placeSearchResult | PlaceDetailsIf it has no details, the card calls api.place(place.id, { language }) and keeps the basic result on failure.
languageLanguageRe-fetches details when it changes.
onClose() => voidThe close button.
onDirections(p: SearchResult, mode: 'to' | 'from') => voidThe Directions button calls it with 'to'.
onSelectNearby(p: SearchResult) => voidA tap on one of details.nearby.
onShare(p: SearchResult) => voidOptional override. Default: navigator.share({ title, text, url }), falling back to copying window.location.href (which carries the map hash when hash is on).
onCopy(text: string, kind: 'text' | 'link' | 'coords') => voidCalled after navigator.clipboard.writeText settles, so you can show a toast.
extraReactNodeSlot rendered under the action row.
compactbooleanHides the hero (photo, skeleton or coloured fallback).

The hero photo is loaded when tags.wikidata is present; the image src is api.photoUrl(photo) and a failed load drops the hero. Rows are shown when the data exists: address (copies), opening hours (an expandable weekly table when details.hours parsed, plain text when hours.unparsed), phone (copies), website, Instagram, Telegram, cuisine, wheelchair, population, elevation (ele), description, and always coordinates (copies, kind: 'coords', five decimals). The footer links to details.osm.url.

OpenStatus{ d: PlaceDetails['details']; language: Language }. Renders nothing unless d.open_status?.known. Otherwise one of: open 24 hours; open (or "closing soon") with the closing time; closed with the next opening time, prefixed with "tomorrow" or a weekday name when next_day is set.

RoutePanel#

packages/ui/src/RoutePanel.tsx.

ts
interface RouteEndpoint {
  label: string;
  lngLat: [number, number];
  kind: 'me' | 'pin' | 'place';
  place?: SearchResult;
}
PropTypeNotes
languageLanguage
from, toRouteEndpoint | nullkind: 'me' shows the locate icon in the field.
modeRouteModeTabs are rendered for car, foot, bike and taxi only. RouteMode in @uzmaps/api also allows truck and motorcycle; with those no tab is active.
onMode(m: RouteMode) => void
options{ avoidTolls: boolean; avoidHighways: boolean; shortest: boolean }
onOptions(o: RoutePanelProps['options']) => voidCalled with the full object, one flag toggled.
routesRoute[]The first route is labelled "recommended"; an alternative within 30 s of it is labelled "alternative", otherwise +duration.
selectedIdstring | null
onSelectRoute(id: string) => void
loadingbooleanWith previous routes present they stay visible at reduced opacity; otherwise two skeleton cards.
errorstring | nullLowercased and matched against /unavailable|engine|fetch|network|timeout|timed out|503|502|500|failed to|econn|abort/ to choose between "routing service unavailable" and "no route found". The raw message is shown beneath.
onSwap() => voidDisabled when both endpoints are empty.
onEditEndpoint, onClearEndpoint, onUseMyLocation(which: 'from' | 'to') => void
onClose() => void
onStepHover(step: RouteStep | null) => voidMouse enter/leave on a step.
onStepClick(step: RouteStep) => voidClick, or keyboard activation.
activeEndpoint'from' | 'to' | nullHighlights the field being edited.
onRetry() => voidAdds a Retry button to the error state.
editorReactNodeSlot rendered under the mode tabs for the endpoint search UI.

The toll/highway/shortest chips are hidden when mode is foot or bike, and whenever editor is set. Steps are the concatenation of route.legs[].steps; the list supports ArrowUp/Down/Left/Right, Home and End. Step durations under 45 s are not shown next to the distance, since they would all read as "1 min".

MapControls and ScaleBar#

packages/ui/src/MapControls.tsx. Both must be rendered inside MapView; they call useUzMap().

PropTypeDefaultNotes
languageLanguagerequired
themeThemerequired'light' | 'dark' | 'auto'.
onTheme(t: Theme) => voidrequiredSegmented control in the layers menu.
onLanguage(l: Language) => voidrequiredMenu lists uz, uz-Cyrl, ru, en using languageNames.
on3D(v: boolean) => voidCalled after the user toggles perspective. The camera is already driven by map.toggle3D(); use this only to persist a preference.
terrainbooleanChecked state of the terrain item.
onTerrain(v: boolean) => void
terrainAvailablebooleanThe terrain item is rendered only when true.
trafficAvailablebooleanThe traffic item is rendered only when true, because there is no open real-time feed for Uzbekistan and a toggle that can never show anything looks broken.
onLocate() => voidrequiredThe locate button.
locating, locatedbooleanSpinner / active state of the locate button.
statusStatus | nullFrom api.status(). Rendered as read-only badges under "Map data": tiles, search index, routing, photos, then terrain and any datasets from status.sources (and from the engine's sources event), merged on a canonical id so buildings_ml and mlBuildings become one badge.
topnumber12Offset from the top in px.
bottomnumber

What the component calls on the map directly: zoomIn(), zoomOut(), rotateBy(-30) / rotateBy(30), toggle3D(), setTraffic(next), and on the compass resetView() (plus set3D(false) if buildings are on) when pitched, otherwise resetNorth(). It subscribes to move, tilt, styleready and sources. The rotate arrows appear only while the map is in 3D mode or the view is tilted or rotated. The 3D button is labelled with the mode it switches to ("2D" while in 3D).

ScaleBar{ language: Language }. Recomputes on every move by unprojecting 100px at mid-height through map.ml, snapping to a 1-2-5 series between 1 m and 500 km. Hidden at max-width: 720px by the stylesheet.

BottomSheet#

packages/ui/src/BottomSheet.tsx.

ts
type SheetSnap = 'peek' | 'half' | 'full';
PropTypeDefaultNotes
childrenReactNoderequired
snapSheetSnaprequiredControlled position.
onSnap(s: SheetSnap) => voidrequiredRaised after a drag, a tap on the handle (cycles peek → half → full → half), or ArrowUp/ArrowDown/Enter/Space on the handle.
peekHeightnumber128px.
halfRationumber0.46Fraction of the container height.
topInsetnumber72px kept free above the sheet at full.
onHeight(px: number) => voidCurrent height, so map padding can follow.
headerReactNodeRendered under the handle; dragging on it moves the sheet.
classNamestring

Heights are computed from the parent element's clientHeight (falling back to window.innerHeight), so the sheet must be inside a sized, positioned container. The body scrolls only at full; at other snaps it gets is-locked. A drag on the body at full is decided on the first move: pulling down from scrollTop === 0 drags the sheet, pushing up scrolls. Release projects the flick velocity 180 ms ahead and snaps to the nearest stop; a decisive flick always moves at least one stop. A non-passive touchmove listener cancels the browser's own scroll while the sheet is being dragged. The root is role="dialog" aria-modal={false}.

Icons#

packages/ui/src/icons.tsx.

  • poiIcons: Record<string, IconComponent> maps every UzMap icon name to a Lucide React component. Its keys are the same 88 names as glyphs in @uzmaps/cartography (listed under Category and icon tables below). Six are hand-drawn inline rather than Lucide: dentist, metro, monument, mosque, synagogue, toilets. Two differ from the sprite glyph: tram renders Lucide TrainTrack and college renders BookOpen.
  • PoiIcon({ icon, ...svgProps }) renders poiIcons[icon], falling back to MapPin for unknown names.
  • ui is an object of Lucide components used across the components: Search, X, ArrowLeft, Navigation, Locate, Layers, Sun, Moon, Compass, Plus, Minus, Phone, Globe, Clock, Copy, Share2, ArrowUpDown, MapPin, Route, CornerUpLeft, CornerUpRight, ArrowUp, ArrowDown, RotateCcw, RotateCw, Merge, Split, MoveUpLeft, MoveUpRight, CircleDot, Footprints, Bike, Car, CarTaxiFront, Ship, ChevronRight, ChevronDown, Info, ExternalLink, Users, Star, Check, TriangleAlert, Crosshair, History, Box, MountainSnow, Languages, AtSign, Send, Circle, Ellipsis, Sparkles, MapIcon, Accessibility, Flag, Mountain, TrafficCone.
  • maneuverIcon(type, modifier) picks a component for a RouteStep:
typeComponent
departCircleDot
arriveFlag
roundabout, roundabout_exitRotateCw
uturnRotateCcw if modifier === 'left', else RotateCw
mergeMerge
forkSplit
ferry, ferry_exitShip
exit, rampMoveUpLeft if modifier contains left, else MoveUpRight
any other, by modifier: left, sharp_leftCornerUpLeft
right, sharp_rightCornerUpRight
slight_leftMoveUpLeft
slight_rightMoveUpRight
straight or anything elseArrowUp

Translations#

packages/ui/src/i18n.ts.

  • t(lang: Language, key: string): string — looks the key up in the dictionary for lang, then in English, then returns the key itself. There are three dictionaries: uz, ru, en; uz-Cyrl is mapped to the Russian dictionary.
  • makeT(lang) returns (key) => t(lang, key).
  • languageNames: Record<Language, string> is { uz: 'O‘zbekcha', 'uz-Cyrl': 'Ўзбекча', ru: 'Русский', en: 'English' }.

Hooks#

packages/ui/src/hooks.ts.

HookSignatureNotes
useDebouncedValue<T>(value, delay)→ T
useMediaQuery(query)→ booleanfalse during SSR.
useIsMobile()→ booleanuseMediaQuery('(max-width: 720px)'), the same breakpoint the stylesheet uses.
useSearch(api, opts?)→ { query, setQuery, results, intent, loading, error, submit, clear }opts: { near?: () => { lat, lon } | undefined; language?: Language; limit?: number; delay?: number }. Typing calls api.autocomplete (limit default 8) after a delay of 90 ms; submit() calls api.search (limit default 20). Each request aborts the previous one and late responses are discarded. error is the message string or null.
useRecentSearches(max = 8)→ { items, add, remove, clear }SearchResult[] persisted in localStorage under uzmap.recent; add de-duplicates by id; remove(id: number).
useLocalStorage<T>(key, initial)→ [T, (v: T | ((p: T) => T)) => void]JSON in localStorage; storage failures are swallowed.
useToast(duration = 1800)→ { msg, show }msg is the string or null.
useListNav(count, onSelect)→ { active, setActive, onKeyDown }ArrowUp/Down wrap around; Enter calls onSelect(active). active resets to -1 whenever count changes.
useStableCallback(fn)→ fnIdentity-stable wrapper that always calls the latest fn.

Example#

Names below are all from the files above and from packages/engine/src/map.ts (selectPlace(place, { fly }), where place may be a SearchResult).

tsx
import { useState } from 'react';
import { UzMapClient, type Language, type SearchResult } from '@uzmaps/api';
import type { Theme } from '@uzmaps/engine';
import { MapView, SearchBox, ResultList, MapControls, ScaleBar, useUzMap, useSearch } from '@uzmaps/ui';
import '@uzmaps/ui/styles.css';

const api = new UzMapClient({ baseUrl: 'https://uzmaps.ndc.uz', language: 'uz' });

function Search({ language }: { language: Language }) {
  const map = useUzMap();
  const { query, setQuery, results, loading, submit } = useSearch(api, { language });
  return (
    <div className="uz-panel">
      <SearchBox value={query} onChange={setQuery} onSubmit={submit} loading={loading} language={language} listId="results" />
      <div className="uz-surface uz-panel-card">
        <div className="uz-panel-scroll">
          <ResultList
            id="results"
            results={results}
            query={query}
            language={language}
            onSelect={(r: SearchResult) => map?.selectPlace(r, { fly: true })}
          />
        </div>
      </div>
    </div>
  );
}

export default function App() {
  const [theme, setTheme] = useState<Theme>('auto');
  const [language, setLanguage] = useState<Language>('uz');
  return (
    <div className="uz-app" data-uz-theme={theme === 'dark' ? 'dark' : undefined}>
      <MapView serverUrl="https://uzmaps.ndc.uz" theme={theme} language={language} center={[69.2401, 41.2995]} zoom={12}>
        <Search language={language} />
        <MapControls theme={theme} onTheme={setTheme} language={language} onLanguage={setLanguage} onLocate={() => {}} />
        <ScaleBar language={language} />
      </MapView>
    </div>
  );
}

data-uz-theme is set by the application here because the stylesheet only reacts to that attribute. With theme === 'auto' the map picks its own basemap theme, but the panels stay light unless you resolve prefers-color-scheme yourself and set the attribute.


@uzmaps/cartography#

Install#

bash
npm install @uzmaps/cartography

packages/cartography/package.json declares one peer, @maplibre/maplibre-gl-style-spec >=23. style.ts imports only types from it (import type { LayerSpecification, StyleSpecification }), so nothing is loaded from that package at runtime.

Everything exported#

From packages/cartography/src/index.ts:

Export
buildStyle, anchors, buildingLayers, sourceId, terrainSourceId, mlBuildingsSourceId, placesSourceId, basemapSourceIds, HOVER_BUILDING_SOURCE, TRAFFIC_SOURCE, DISTRICTS_SOURCE, trafficLayers, type StyleOptions
themes, light, dark, fonts, types ThemeName, ThemeTokens
groups, groupById, iconGroup, poiSubclassIcon, poiClassIcon, iconPriority, type CategoryGroup
glyphs, badgeIcons, rawIcons
nameExpr, type Language

Exported from a module but not from the package index: furnitureSourceId, customGlyphs and the BadgeIcon / RawIcon / SpriteIcon types, and the expression helpers in expr.ts (zoomLin, zoomExp, zoomMatch, match, …).

buildStyle(options)#

packages/cartography/src/style.ts. Returns a plain StyleSpecification (version: 8) that you can edit before handing to MapLibre.

OptionTypeDefaultNotes
theme'light' | 'dark''light'
language'uz' | 'uz-Cyrl' | 'ru' | 'en''uz'Drives nameExpr() for every label.
serverUrlstring''A trailing slash is stripped. Used to build the three URLs below.
tilesUrlstring${serverUrl}/tiles/tilejson.jsonVector source URL; a pmtiles:// URL or TileJSON.
glyphsUrlstring${serverUrl}/fonts/{fontstack}/{range}.pbf
spriteUrlstring${serverUrl}/sprites/sprite
terrainUrlstringRaster-DEM TileJSON URL. Either this or terrainTiles enables terrain.
terrainTilesstring[]Raster-DEM tile templates.
terrainEncoding'terrarium' | 'mapbox''mapbox'
buildings3dbooleanfalseSets the initial visibility of the flat versus extruded building layers.
poisbooleanshownfalse omits the POI layers.
labelsbooleanshownfalse omits every label layer (water names, road names, house numbers, POIs, place names).
hillshadebooleanon when terrain is setfalse drops the hillshade source and layer.
density'full' | 'muted''full''muted' dims landuse and landcover to 55% and POI icons/text to 75%/80%.
mlBuildingsUrlstringTileJSON of the ML building footprints archive (source-layer building, optional numeric height). Adds uz-building-ml* layers.
placesUrlstringTileJSON of the Overture places archive (source-layer poi; name, name:ru, icon, group, category, rank 1–5). Adds uz-poi-places.
furnitureUrlstringTileJSON of the street-furniture archive (source-layer furniture, kind of tree_row, crossing_way, crossing, tree). Adds the uz-tree* and uz-crossing* layers.

Other properties of the returned style:

  • name is `UzMap ${theme} (${language})` and metadata is { 'uzmap:theme', 'uzmap:language', 'uzmap:version': 1 }.
  • light is viewport-anchored with position: [1.15, 210, 52] and intensity 0.32 (light) or 0.22 (dark). The polar angle is high and the intensity low on purpose: MapLibre's intensity is contrast, and a near-horizon light crushes walls facing away from it to near-black.
  • transition is { duration: 300, delay: 0 }.
  • When a terrain source is present, terrain is { source: 'uzmap-terrain', exaggeration: 1.15 } and a sky block is set from the theme's sky and land tokens.

Sources#

IdExported asPresentContents
uzmapsourceIdalwaysThe OpenMapTiles vector tiles, with OpenStreetMap attribution.
uzmap-hover-buildingHOVER_BUILDING_SOURCEalwaysEmpty GeoJSON. The engine writes the footprint under the cursor here; hover is a plain GeoJSON layer rather than feature-state because the tiles carry no stable ids.
uzmap-trafficTRAFFIC_SOURCEalwaysEmpty GeoJSON. LineStrings with a congestion property of free, moderate, heavy, severe or closed; anything else is drawn in the free-flow colour at 45% opacity.
uzmap-districtsDISTRICTS_SOURCEalwaysEmpty GeoJSON. District (level <= 9) and mahalla (level == 10) label points, which the engine fetches from /api/districts because OSM maps them as boundary relations and Planetiler emits them without a name.
uzmap-terrainterrainSourceIdwith terrainraster-dem, tileSize: 256.
uzmap-hillshadewith terrain, unless hillshade: falseA second raster-dem source, because the terrain mesh and the hillshade need different tile handling and sharing one degrades both.
uzmap-buildings-mlmlBuildingsSourceIdwith mlBuildingsUrlMicrosoft Building Footprints attribution.
uzmap-placesplacesSourceIdwith placesUrlOverture Maps attribution.
uzmap-furniturefurnitureSourceId (module only)with furnitureUrl

basemapSourceIds is ['uzmap', 'uzmap-terrain', 'uzmap-hillshade', 'uzmap-buildings-ml', 'uzmap-places'] — every source the generator owns, so the engine knows not to carry them over as runtime overlays.

Layer anchors#

ts
export const anchors = {
  overlayFill: 'uz-anchor-overlay-fill',
  overlayLine: 'uz-anchor-overlay-line',
  overlaySymbol: 'uz-anchor-overlay-symbol',
} as const;

Each anchor is a background layer with visibility: 'none' — it paints nothing and exists only as a stable id. All layer ids in the style are namespaced uz-* so overlays can be inserted at known positions. MapLibre's map.addLayer(layer, beforeId) inserts before the named layer, so a layer added against an anchor lands immediately below it in the draw order:

AnchorLayers below itLayers above itIntended for
uz-anchor-overlay-fillground (uz-background, uz-landuse, uz-landcover, uz-park, uz-park-outline, uz-pitch), uz-hillshade, wateraeroways, boundaries, flat buildings, roads, furniture, traffic, extruded buildings, labelsZones and other polygons that must sit under the road network.
uz-anchor-overlay-lineeverything above, through the extruded buildingswater names, road shields and names, house numbers, POIs, place namesRoutes and other lines: above roads, below labels.
uz-anchor-overlay-symbolevery label layernothingMarkers: above everything.

The full order, from buildStyle: ground → hillshade → water → overlay-fill → aeroway → boundaries → flat buildings (uz-building-ml-wall, uz-building-ml, uz-building-wall, uz-building, uz-building-hover, uz-building-hover-line) → roads (uz-road-lowzoom, tunnels, uz-track, uz-path, pedestrian, uz-road-casing, uz-road-fill, uz-road-marking, uz-road-area, rail, bridges, uz-oneway) → furniture → uz-traffic-casing, uz-traffic → extruded buildings (uz-building-ml-base, uz-building-base, uz-building-ml-3d, uz-building-3d, uz-building-hover-3d) → overlay-line → labels → overlay-symbol.

Flat footprints are drawn under the roads because ML footprints traced from imagery often overlap the roadway by a few metres; painted on top they cover the street. Extrusions are drawn above the roads because in a pitched view a building stands in front of the road behind it.

Other layer constants#

  • buildingLayers.flat = ['uz-building-wall', 'uz-building-ml-wall', 'uz-building', 'uz-building-ml', 'uz-building-hover', 'uz-building-hover-line']; buildingLayers.threeD = ['uz-building-base', 'uz-building-ml-base', 'uz-building-3d', 'uz-building-ml-3d', 'uz-building-hover-3d']. The engine toggles visibility between the two sets. In 2D the "wall" layer is a darker copy of each footprint translated a fraction of a pixel down-right, which is how a flat overhead map gets its sense of height.
  • trafficLayers = ['uz-traffic-casing', 'uz-traffic']. Both start with visibility: 'none'; the engine turns them on with setTraffic().

Example#

ts
import maplibregl from 'maplibre-gl';
import { buildStyle, anchors, themes } from '@uzmaps/cartography';

const map = new maplibregl.Map({
  container: 'map',
  style: buildStyle({ serverUrl: 'https://uzmaps.ndc.uz', theme: 'dark', language: 'ru' }),
  center: [69.2401, 41.2995],
  zoom: 12,
});

map.on('load', () => {
  map.addSource('zones', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  // Inserted before the anchor, so it renders under the roads and above the water.
  map.addLayer(
    { id: 'zones', type: 'fill', source: 'zones', paint: { 'fill-color': themes.dark.route.alt, 'fill-opacity': 0.35 } },
    anchors.overlayFill,
  );
});

nameExpr(language)#

packages/cartography/src/expr.ts. Returns a coalesce expression over name properties, in this order:

LanguageProperties tried
uzname:uz-Latn, name:latin, name:uz, name — some OSM objects carry Cyrillic in name:uz, so explicit Latin and the OpenMapTiles-derived Latin come first.
uz-Cyrlname:uz-Cyrl, name:ru, name
runame:ru, name:uz-Cyrl, name
enname:en, name_en, name:latin, name

The Overture places layer (uz-poi-places) uses a simpler rule: name:ru then name for ru and uz-Cyrl, otherwise name.

Theme tokens#

packages/cartography/src/tokens.ts. ThemeName is 'light' | 'dark'; themes is { light, dark }, each a ThemeTokens. fonts is { regular: ['Noto Sans Regular'], medium: ['Noto Sans Medium'], italic: ['Noto Sans Italic'] }.

Ground, areas and water:

TokenLightDark
land#F1F0EC#1F2126
landDesert#EFE9DB#232323
landFarm#EEF0E2#20241F
landGrass#CADFAD#232D20
landWood#A4C987#1B3524
landWetland#D5E6DE#1E2A2B
landIce#F6F8FA#2A2E33
landRock#E6E1DA#26272A
park#CCE1B2#22321F
parkOutline#B7D7AB#2A3D2C
cemetery#D8E3D1#232C24
residential#EFEDE7#23252A
commercial#F3ECE3#27262A
industrial#EBEAE7#242629
retail#F4E9DF#28262A
hospital#F5E7E5#2A2527
school#F1ECDF#282824
military#EDE9E0#262624
airport#EAEAEA#25272B
pitch#D2E6C8#233228
water#79BAEA#183950
waterOutline#9CC6E8#1D3449
waterway#A8CDEB#1E3A52
aeroway#DCDCDC#2E3136

Buildings:

TokenLightDarkPurpose
building#E7E3DD#30353EFlat footprint fill.
buildingOutline#D2CCC3#3E4550
buildingWall#C6B7A0#191C21The offset copy under each footprint that gives 2D views depth.
buildingHover#C7B393#4A5360A deeper shade of the building rather than an accent tint, so hover does not read as selection.
buildingHoverOutline#8F7A55#6E7C90
buildingBase#D6D1C8#24272CDarker footprint under extrusions in 3D.
buildingBaseOutline#C7C1B7#1E2125
building3d#E8DFD1#31353CExtrusion wall at 0 m.
building3dMid#E2DACD#373C44At 24 m.
building3dTop#D8D0C4#3E434BAt 90 m.
light3d#FFFFFF#D6DCE8Colour of the directional light.

road.*:

TokenLightDark
motorway / motorwayCasing#FFFFFF / #AEB5C1#7C6437 / #4A3B1F
trunk / trunkCasing#FFFFFF / #B4BAC6#6F5C39 / #43371F
primary / primaryCasing#FFFFFF / #BCC2CD#565144 / #2C2A24
secondary / secondaryCasing#FFFFFF / #C6CBD5#45484F / #26282C
minor / minorCasing#FFFFFF / #D0D4DC#3C3F45 / #24262A
service / serviceCasing#FCFCFD / #DADDE3#33363B / #24262A
asphalt / asphaltCasing#9AA2B2 / #848D9E#4A505C / #383D47
crossing#FFFFFF#AEB4BE
tree#7CA85F#4C6B41
marking#FFFFFF#C9CEd8
pedestrian / pedestrianCasing#F0ECE3 / #DCD6CA#34373C / #2A2C30
path#B9B2A4#5A5D63
track#C9B899#55503F
tunnel / tunnelCasing#F3F1EC / #D9D4CB#2C2E33 / #3A3D43
rail / railHatch#AFA99F / #F4F2ED#4B4E55 / #1F2126
lowZoomMotorway#EEB556#8B7040
lowZoomTrunk#F2C674#75633C
lowZoomPrimary#E1D3AE#585549
lowZoomSecondary#D6D0C2#44464B
oneway#9A948A#8A8D93
construction#D6D0C4#3E4045

The classed road colours are used up to z16.5 and crossfade to asphalt / asphaltCasing by z18. Across a city the class colour is what makes the network legible; standing over one junction it is noise, and the width already carries the classification. marking is the painted centre line, drawn from z17 on motorway through tertiary.

boundary.*:

TokenLightDark
country#9C90AE#7E7396
region#B3A9C4#5F5874
district#C4BCD1#4E4960
countryHalo#F4F2ED#1F2126

text.*:

TokenLightDark
place / placeHalo#2A2D33 / #F4F2ED#D9DBE0 / #1F2126
city#1F2226#E8EAEE
capital#141618#F5F6F8
minorPlace#5A5E66#A4A8B0
street / streetHalo#4B4E55 / #FFFFFF#B7BAC1 / #1F2126
streetMajor#3D4046#CFD2D8
water / waterHalo#3F7AB0 / #E4F0FA#6FA3D6 / #16293A
poi / poiHalo#3B3F47 / #FFFFFF#D3D6DC / #1F2126
housenumber#8B877E#7E8289
peak#6B5C48#B7AA94
shield / shieldBg / shieldBorder#4A3A14 / #FFF1BF / #D9B65B#F1E3B4 / #4A3F22 / #8A7440
country#6C6577#9A93AB

Terrain, overlays and accents:

TokenLightDark
hillshadeShadow#4F4634#000000
hillshadeHighlight#FFFFFF#6B6F7A
sky#C8DFF5#0F1620
route.primary / route.primaryCasing#2F7CF6 / #1E5DC4#4D8DFF / #1E4FA8
route.alt / route.altCasing#9DB6D8 / #7F98BB#5D6B85 / #465267
route.walk#2F7CF6#4D8DFF
selection#2F7CF6#4D8DFF
trafficFree#3FAE59#3E9E55
trafficModerate#F0C020#D2A82A
trafficHeavy#EE7A31#D4762F
trafficSevere#D33A32#C4453C
trafficClosed#8E2F2A#8A3733
accent#2F7CF6#4D8DFF

The traffic ramp is deliberately not the UI's success/warning/danger palette: it sits directly on the roadway and has to stay legible over both themes.

Category and icon tables#

packages/cartography/src/categories.ts and icons.ts. The taxonomy is shared with the server (services/server/internal/taxonomy) and has to be kept in sync with it.

ts
interface CategoryGroup { id: string; icon: string; color: string; label: Record<string, string> }

groups (18 entries; groupById is the same list keyed by id):

idiconcolouren label
foodrestaurant#E8712BFood & drink
shoppingshopping#D9489CShopping
healthhealth#E0454BHealth
educationeducation#5B6CDBEducation
financebank#2E8B57Finance
transporttransport#3A7BD5Transport
autofuel#5A6B7CAuto
lodginghotel#8E5AD6Lodging
cultureculture#B4631ECulture & sights
religionmosque#5F7C5AReligion
leisurepark#3E9B4FLeisure & sport
servicesservices#6B7280Services
beautybeauty#D46AA0Beauty
officeoffice#7A8699Offices
naturenature#4F8A3CNature
placeplace#4B5563Places
streetstreet#4B5563Streets
addressaddress#4B5563Addresses

Labels are provided for uz, ru and en.

Icon names → group (iconGroup). These 88 names are the keys of glyphs here and of poiIcons in @uzmaps/ui, and are what SearchResult.icon carries:

GroupIcon names
foodrestaurant, cafe, fast-food, teahouse, bar, bakery, ice-cream
shoppingsupermarket, shop, mall, bazaar, clothes, electronics, furniture, books, florist, jewelry, shopping
healthhospital, clinic, pharmacy, dentist, veterinary, health
educationuniversity, school, college, kindergarten, library, education
financebank, atm, exchange
transportairport, train, metro, bus, bus-stop, taxi, transport, tram
autoparking, fuel, charging, car-wash, car-repair, car
lodginghotel
culturemuseum, theatre, cinema, gallery, attraction, monument, viewpoint, historic, zoo, culture
religionmosque, church, synagogue
leisurepark, playground, stadium, sports, fitness, pool, theme-park
servicespost, police, government, embassy, fire, toilets, services
beautybeauty
officeoffice, travel
naturenature, peak, water, beach
placeplace, city, village, poi
streetstreet
addressaddress

Sprite manifest:

  • glyphs: Record<string, string> maps each icon name to a Lucide glyph file name (e.g. restaurant → utensils, dentist → tooth, metro → metro, tram → tram-front, college → book-marked). Eight glyphs are hand-drawn because Lucide has no equivalent: tooth, metro, obelisk, mosque, synagogue, wc, tram-front, book-marked.
  • badgeIcons() returns one { kind: 'badge', name, glyph, color, size: 22 } per glyph, with color from the icon's group (default #4B5563). The style references these as sprite id poi-<name> (for example poi-cafe); an icon name the style cannot map falls back to poi-poi so that a missing sprite does not also drop the label.
  • rawIcons lists the hand-drawn SVGs under packages/cartography/icons/raw/: shield-light and shield-dark (stretchable, used for road refs), oneway, dot, dot-ring, star, peak, marker, airport-plane and entrance (SDF, tintable), and marker-shadow, route-start, route-end, route-via, user-location, metro-m (plain).

OpenMapTiles poi layer → icon. poiSubclassIcon is tried first, then poiClassIcon; a POI matching neither is not rendered on the basemap (it remains searchable through the API).

Iconsubclass valuesclass values
restaurantrestaurantrestaurant
cafecafe, coffeecafe
fast-foodfast_food, food_courtfast_food
barbar, pub, biergartenbar, beer
ice-creamice_creamice_cream
bakerybakery, pastry, confectionerybakery
supermarketsupermarketgrocery
shopconvenience, grocery, greengrocer, giftshop, alcohol_shop
mallmall, department_store
bazaarmarketplace
clothesclothes, shoes, boutiqueclothing_store
electronicselectronics, mobile_phone, computer
furniturefurniture
booksbooks, stationery
floristflorist
jewelryjewelry
hospitalhospitalhospital
clinicclinic, doctorsdoctors
pharmacypharmacypharmacy
dentistdentistdentist
veterinaryveterinaryveterinary
universityuniversityuniversity, college
collegecollege
schoolschool, language_schoolschool
kindergartenkindergarten
librarylibrarylibrary
bankbankbank
atmatmatm
exchangebureau_de_change
airportaerodromeairport
trainstation, haltrailway
metrosubway
busbus_stationbus
bus-stopbus_stop
tramtram_stop
taxitaxi
parkingparkingparking
fuelfuelfuel
chargingcharging_station
car-washcar_wash
car-repaircar_repair
carcarcar
hotelhotel, motel, hostel, guest_houselodging, hotel
museummuseummuseum
theatretheatretheatre
cinemacinemacinema
gallerygallery, arts_centreart_gallery
attractionattractionattraction, information
monumentartwork, monument, memorialmonument
viewpointviewpoint
zoozoozoo
theme-parktheme_park
historiccastle, ruins, archaeological_sitecastle
mosqueplace_of_worshipplace_of_worship
parkpark, gardenpark
playgroundplaygroundplayground
stadiumstadiumstadium
sportssports_centre, pitchpitch, golf
fitnessfitness_centre
poolswimming_pool, water_parkswimming
postpost_officepost
policepolicepolice
governmenttownhall, courthousetown_hall
firefire_station
embassyembassyembassy
toiletstoiletstoilets
beautyhairdresser, beauty, cosmeticshairdresser
traveltravel_agency
serviceslaundry, dry_cleaninglaundry
naturecemetery, campsite

Every place_of_worship maps to mosque because the OpenMapTiles schema does not carry the religion tag, and mosques dominate in Uzbekistan.

iconPriority — higher means shown at a lower zoom. The uz-poi layer shows an icon when its priority is at least the zoom threshold (z13: 88, z14: 70, z15: 50, z16: 34, z17: 20, z18: 0) and its rank is within the zoom's limit (z13: 1, z14: 3, z15: 8, z16: 20, z17: 60, z18: 200). Icons not in the table get priority 10.

PriorityIcons
100airport
90train
88metro
80stadium
78university
76hospital
75mall
74bazaar
72museum, historic
70bus, theatre
68attraction
66zoo, theme-park
64park
60monument, mosque
58church
55hotel, government
54embassy
52cinema
50gallery, supermarket
48bank
46college
44library, fuel
42clinic
40school, pharmacy, restaurant, teahouse, viewpoint
38cafe
36police, pool
34fast-food, sports
32bar
30bakery, tram, charging, fitness, post
28electronics, dentist, kindergarten, car, fire
26ice-cream, clothes, books, exchange
24taxi, shop, veterinary
22furniture, jewelry, car-repair, playground, beauty
20parking, florist, car-wash, travel
18bus-stop, services
16atm, office
10toilets

Metro stations, railway stations and bus stops are drawn by the separate uz-poi-transit layer (sprites metro-m, poi-train, poi-bus-stop), airports by uz-aerodrome (airport-plane), and named peaks by uz-peak (peak).


Unverified#

  • UzMapOptions defaults (autoTiltFlatZoom 12.6, autoTiltZoom 15.6, tiltMax 50, tiles 'server', sources true, maxBounds Uzbekistan, terrainUrl) are quoted from the doc comments in packages/engine/src/types.ts and were confirmed against the constructor, detectSources() and the style URL code in packages/engine/src/map.ts; the rest of map.ts was only checked for method signatures, not read in full.
  • The dist/ output of either package was not compared with src/; this page describes the source at the versions in package.json (@uzmaps/ui 0.1.1, @uzmaps/cartography 0.1.0).
  • map.userLocation is a UserLocation with start(opts?: PositionOptions) and stop(), so the map.userLocation.start() call in packages/ui/README.md is valid; the example above still uses a no-op onLocate and the geolocation behaviour of UserLocation was not read.
  • The lucide-react dependency range in packages/ui/package.json is ^1.39.0; the locked and installed version is 1.39.0 and every icon icons.tsx imports resolves in it.
  • Server-side behaviour of the endpoints PlaceCard uses (/api/place/:id, /api/photo) was not read; only the client signatures in packages/api/src/index.ts were.
  • The README's claim that its samples are compiled in CI from examples/ is backed by .github/workflows/ci-cd.yml (npx tsc -p examples --noEmit) and examples/ui.tsx; the workflow was not otherwise read.

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