@uzmaps/ui and @uzmaps/cartography
Two packages sit on top of @uzmaps/engine and @uzmaps/api:
| Package | Version | What it is |
|---|---|---|
@uzmaps/ui | 0.1.1 | React components (map host, search box, result list, place card, route panel, map controls, bottom sheet), a stylesheet, icons, translations and hooks. |
@uzmaps/cartography | 0.1.0 | buildStyle(), 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#
npm install @uzmaps/ui @uzmaps/engine @uzmaps/api react react-domFrom 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#
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:
| Selector | What it does |
|---|---|
.uz-app | Scope 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-map | Set 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:
| Component | What it owns |
|---|---|
MapView | Creates and destroys the UzMap instance and provides it through context. |
MapControls, ScaleBar | Read 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. |
PlaceCard | Takes an api: UzMapClient and fetches place details and the photo itself. |
Everything exported#
From packages/ui/src/index.ts:
| Export | Kind |
|---|---|
MapView, useUzMap, MapViewProps | component, hook, type |
SearchBox, ResultList, ResultIcon, EmptyState, CategoryChips, highlight, SearchBoxProps, ResultListProps | components, function, types |
PlaceCard, OpenStatus, PlaceCardProps | components, type |
RoutePanel, RoutePanelProps, RouteEndpoint | component, types |
MapControls, ScaleBar, MapControlsProps | components, type |
BottomSheet, BottomSheetProps, SheetSnap | component, types |
PoiIcon, poiIcons, maneuverIcon, ui | component, table, function, table |
t, makeT, languageNames | functions, table |
useDebouncedValue, useMediaQuery, useIsMobile, useSearch, useRecentSearches, useLocalStorage, useToast, useListNav, useStableCallback | hooks |
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.
interface MapViewProps extends Omit<UzMapOptions, 'container'> {
className?: string;
style?: React.CSSProperties;
children?: ReactNode;
onReady?: (map: UzMap) => void;
}
function useUzMap(): UzMap | nullMapView 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 change | Engine call |
|---|---|
theme | map.setTheme(theme) |
language | map.setLanguage(language) |
buildings3d | map.set3D(buildings3d) |
terrain | map.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):
| Option | Type | Notes |
|---|---|---|
serverUrl | string | Base 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, maxZoom | number | |
maxBounds | [w, s, e, n] | null | Defaults to Uzbekistan with a margin; null disables. |
hash | boolean | Sync the camera with the URL hash. |
buildings3d | boolean | |
autoTilt | boolean | Pitch follows zoom. The curve is flat by default. |
autoTiltFlatZoom | number | Default 12.6. |
autoTiltZoom | number | Default 15.6. |
autoTiltPitchSoft | number | Default 0. |
autoTiltPitch | number | Deprecated alias of autoTiltPitchSoft. |
autoTiltPitchMax | number | Default 0. |
tiltMax | number | Pitch the 3D button animates to. Default 50. |
terrain | boolean | |
terrainUrl | string | Default ${serverUrl}/terrain/tilejson.json when terrain is true. |
pois, labels | boolean | Passed 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. |
apiKey | string | Sent as a header on API calls and as ?key= on tile, glyph and sprite URLs. |
interactive, attributionControl | boolean | |
interactiveLayers | string[] | Which basemap layers emit poi:click / poi:hover. |
locale | Record<string, string> | MapLibre UI strings. |
transformStyle | (style: StyleSpecification) => StyleSpecification | Called once with the generated style before it is applied. |
SearchBox#
packages/ui/src/SearchBox.tsx.
| Prop | Type | Default | Notes |
|---|---|---|---|
value | string | required | |
onChange | (v: string) => void | required | Also called with '' by the clear button, which then refocuses the input. |
onSubmit | () => void | Enter. | |
onFocus, onBlur | () => void | ||
onKeyDown | (e: KeyboardEvent<HTMLInputElement>) => void | Runs before the built-in handling. Call e.preventDefault() to suppress Enter → submit or Escape → blur for that key. | |
loading | boolean | Shows .uz-spinner. | |
language | Language | required | Placeholder and aria-label come from t(). |
placeholder | string | t(language, 'searchPlaceholder') | |
autoFocus | boolean | Focuses the input in an effect. | |
lead | ReactNode | <ui.Search /> | Leading slot, e.g. a back button. |
trail | ReactNode | Trailing slot, rendered after a divider. | |
hint | ReactNode | Rendered only while unfocused and empty. The stylesheet shows .uz-search-hint only at min-width: 721px with hover: hover and a fine pointer. | |
inputRef | RefObject<HTMLInputElement | null> | internal ref | |
listId | string | When set, the input gets role="combobox", aria-controls, aria-expanded="true" and aria-autocomplete="list". Pass the same string as ResultList's id. | |
activeIndex | number | With 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
| Prop | Type | Default | Notes |
|---|---|---|---|
results | SearchResult[] | required | Row key is ${kind}-${id}. |
query | string | '' | Passed to highlight() for the title. |
language | Language | required | |
active | number | -1 | Index of the highlighted row; it is scrolled into view when it changes. |
onHover | (i: number) => void | Mouse enter, and arrow-key focus moves. | |
onSelect | (r: SearchResult) => void | required | Click, Enter or Space on a row. |
onDirections | (r: SearchResult) => void | Adds a directions button per row. | |
onEscape | () => void | Escape while a row has focus. | |
title | string | Section heading; also the listbox aria-label. | |
titleAction | ReactNode | Rendered at the right of the title. | |
showDistance | boolean | true | Shows formatDistance(r.distance, language) when r.distance is set. |
onRemove | (r: SearchResult) => void | Adds a remove button per row (used for recent searches). | |
id | string | Listbox 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.
| Prop | Type | Notes |
|---|---|---|
api | UzMapClient | Used for api.place(), api.photo() and api.photoUrl(). |
place | SearchResult | PlaceDetails | If it has no details, the card calls api.place(place.id, { language }) and keeps the basic result on failure. |
language | Language | Re-fetches details when it changes. |
onClose | () => void | The close button. |
onDirections | (p: SearchResult, mode: 'to' | 'from') => void | The Directions button calls it with 'to'. |
onSelectNearby | (p: SearchResult) => void | A tap on one of details.nearby. |
onShare | (p: SearchResult) => void | Optional 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') => void | Called after navigator.clipboard.writeText settles, so you can show a toast. |
extra | ReactNode | Slot rendered under the action row. |
compact | boolean | Hides 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.
interface RouteEndpoint {
label: string;
lngLat: [number, number];
kind: 'me' | 'pin' | 'place';
place?: SearchResult;
}| Prop | Type | Notes |
|---|---|---|
language | Language | |
from, to | RouteEndpoint | null | kind: 'me' shows the locate icon in the field. |
mode | RouteMode | Tabs 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']) => void | Called with the full object, one flag toggled. |
routes | Route[] | The first route is labelled "recommended"; an alternative within 30 s of it is labelled "alternative", otherwise +duration. |
selectedId | string | null | |
onSelectRoute | (id: string) => void | |
loading | boolean | With previous routes present they stay visible at reduced opacity; otherwise two skeleton cards. |
error | string | null | Lowercased 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 | () => void | Disabled when both endpoints are empty. |
onEditEndpoint, onClearEndpoint, onUseMyLocation | (which: 'from' | 'to') => void | |
onClose | () => void | |
onStepHover | (step: RouteStep | null) => void | Mouse enter/leave on a step. |
onStepClick | (step: RouteStep) => void | Click, or keyboard activation. |
activeEndpoint | 'from' | 'to' | null | Highlights the field being edited. |
onRetry | () => void | Adds a Retry button to the error state. |
editor | ReactNode | Slot 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().
| Prop | Type | Default | Notes |
|---|---|---|---|
language | Language | required | |
theme | Theme | required | 'light' | 'dark' | 'auto'. |
onTheme | (t: Theme) => void | required | Segmented control in the layers menu. |
onLanguage | (l: Language) => void | required | Menu lists uz, uz-Cyrl, ru, en using languageNames. |
on3D | (v: boolean) => void | Called after the user toggles perspective. The camera is already driven by map.toggle3D(); use this only to persist a preference. | |
terrain | boolean | Checked state of the terrain item. | |
onTerrain | (v: boolean) => void | ||
terrainAvailable | boolean | The terrain item is rendered only when true. | |
trafficAvailable | boolean | The 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 | () => void | required | The locate button. |
locating, located | boolean | Spinner / active state of the locate button. | |
status | Status | null | From 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. | |
top | number | 12 | Offset from the top in px. |
bottom | number |
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.
type SheetSnap = 'peek' | 'half' | 'full';| Prop | Type | Default | Notes |
|---|---|---|---|
children | ReactNode | required | |
snap | SheetSnap | required | Controlled position. |
onSnap | (s: SheetSnap) => void | required | Raised after a drag, a tap on the handle (cycles peek → half → full → half), or ArrowUp/ArrowDown/Enter/Space on the handle. |
peekHeight | number | 128 | px. |
halfRatio | number | 0.46 | Fraction of the container height. |
topInset | number | 72 | px kept free above the sheet at full. |
onHeight | (px: number) => void | Current height, so map padding can follow. | |
header | ReactNode | Rendered under the handle; dragging on it moves the sheet. | |
className | string |
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 asglyphsin@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:tramrenders LucideTrainTrackandcollegerendersBookOpen.PoiIcon({ icon, ...svgProps })renderspoiIcons[icon], falling back toMapPinfor unknown names.uiis 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 aRouteStep:
type | Component |
|---|---|
depart | CircleDot |
arrive | Flag |
roundabout, roundabout_exit | RotateCw |
uturn | RotateCcw if modifier === 'left', else RotateCw |
merge | Merge |
fork | Split |
ferry, ferry_exit | Ship |
exit, ramp | MoveUpLeft if modifier contains left, else MoveUpRight |
any other, by modifier: left, sharp_left | CornerUpLeft |
right, sharp_right | CornerUpRight |
slight_left | MoveUpLeft |
slight_right | MoveUpRight |
straight or anything else | ArrowUp |
Translations#
packages/ui/src/i18n.ts.
t(lang: Language, key: string): string— looks the key up in the dictionary forlang, then in English, then returns the key itself. There are three dictionaries:uz,ru,en;uz-Cyrlis 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.
| Hook | Signature | Notes |
|---|---|---|
useDebouncedValue<T>(value, delay) | → T | |
useMediaQuery(query) | → boolean | false during SSR. |
useIsMobile() | → boolean | useMediaQuery('(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) | → fn | Identity-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).
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#
npm install @uzmaps/cartographypackages/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.
| Option | Type | Default | Notes |
|---|---|---|---|
theme | 'light' | 'dark' | 'light' | |
language | 'uz' | 'uz-Cyrl' | 'ru' | 'en' | 'uz' | Drives nameExpr() for every label. |
serverUrl | string | '' | A trailing slash is stripped. Used to build the three URLs below. |
tilesUrl | string | ${serverUrl}/tiles/tilejson.json | Vector source URL; a pmtiles:// URL or TileJSON. |
glyphsUrl | string | ${serverUrl}/fonts/{fontstack}/{range}.pbf | |
spriteUrl | string | ${serverUrl}/sprites/sprite | |
terrainUrl | string | Raster-DEM TileJSON URL. Either this or terrainTiles enables terrain. | |
terrainTiles | string[] | Raster-DEM tile templates. | |
terrainEncoding | 'terrarium' | 'mapbox' | 'mapbox' | |
buildings3d | boolean | false | Sets the initial visibility of the flat versus extruded building layers. |
pois | boolean | shown | false omits the POI layers. |
labels | boolean | shown | false omits every label layer (water names, road names, house numbers, POIs, place names). |
hillshade | boolean | on when terrain is set | false drops the hillshade source and layer. |
density | 'full' | 'muted' | 'full' | 'muted' dims landuse and landcover to 55% and POI icons/text to 75%/80%. |
mlBuildingsUrl | string | TileJSON of the ML building footprints archive (source-layer building, optional numeric height). Adds uz-building-ml* layers. | |
placesUrl | string | TileJSON of the Overture places archive (source-layer poi; name, name:ru, icon, group, category, rank 1–5). Adds uz-poi-places. | |
furnitureUrl | string | TileJSON 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:
nameis`UzMap ${theme} (${language})`andmetadatais{ 'uzmap:theme', 'uzmap:language', 'uzmap:version': 1 }.lightis viewport-anchored withposition: [1.15, 210, 52]and intensity0.32(light) or0.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.transitionis{ duration: 300, delay: 0 }.- When a terrain source is present,
terrainis{ source: 'uzmap-terrain', exaggeration: 1.15 }and askyblock is set from the theme'sskyandlandtokens.
Sources#
| Id | Exported as | Present | Contents |
|---|---|---|---|
uzmap | sourceId | always | The OpenMapTiles vector tiles, with OpenStreetMap attribution. |
uzmap-hover-building | HOVER_BUILDING_SOURCE | always | Empty 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-traffic | TRAFFIC_SOURCE | always | Empty 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-districts | DISTRICTS_SOURCE | always | Empty 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-terrain | terrainSourceId | with terrain | raster-dem, tileSize: 256. |
uzmap-hillshade | — | with terrain, unless hillshade: false | A second raster-dem source, because the terrain mesh and the hillshade need different tile handling and sharing one degrades both. |
uzmap-buildings-ml | mlBuildingsSourceId | with mlBuildingsUrl | Microsoft Building Footprints attribution. |
uzmap-places | placesSourceId | with placesUrl | Overture Maps attribution. |
uzmap-furniture | furnitureSourceId (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#
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:
| Anchor | Layers below it | Layers above it | Intended for |
|---|---|---|---|
uz-anchor-overlay-fill | ground (uz-background, uz-landuse, uz-landcover, uz-park, uz-park-outline, uz-pitch), uz-hillshade, water | aeroways, boundaries, flat buildings, roads, furniture, traffic, extruded buildings, labels | Zones and other polygons that must sit under the road network. |
uz-anchor-overlay-line | everything above, through the extruded buildings | water names, road shields and names, house numbers, POIs, place names | Routes and other lines: above roads, below labels. |
uz-anchor-overlay-symbol | every label layer | nothing | Markers: 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 withvisibility: 'none'; the engine turns them on withsetTraffic().
Example#
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:
| Language | Properties tried |
|---|---|
uz | name: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-Cyrl | name:uz-Cyrl, name:ru, name |
ru | name:ru, name:uz-Cyrl, name |
en | name: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:
| Token | Light | Dark |
|---|---|---|
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:
| Token | Light | Dark | Purpose |
|---|---|---|---|
building | #E7E3DD | #30353E | Flat footprint fill. |
buildingOutline | #D2CCC3 | #3E4550 | |
buildingWall | #C6B7A0 | #191C21 | The offset copy under each footprint that gives 2D views depth. |
buildingHover | #C7B393 | #4A5360 | A deeper shade of the building rather than an accent tint, so hover does not read as selection. |
buildingHoverOutline | #8F7A55 | #6E7C90 | |
buildingBase | #D6D1C8 | #24272C | Darker footprint under extrusions in 3D. |
buildingBaseOutline | #C7C1B7 | #1E2125 | |
building3d | #E8DFD1 | #31353C | Extrusion wall at 0 m. |
building3dMid | #E2DACD | #373C44 | At 24 m. |
building3dTop | #D8D0C4 | #3E434B | At 90 m. |
light3d | #FFFFFF | #D6DCE8 | Colour of the directional light. |
road.*:
| Token | Light | Dark |
|---|---|---|
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.*:
| Token | Light | Dark |
|---|---|---|
country | #9C90AE | #7E7396 |
region | #B3A9C4 | #5F5874 |
district | #C4BCD1 | #4E4960 |
countryHalo | #F4F2ED | #1F2126 |
text.*:
| Token | Light | Dark |
|---|---|---|
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:
| Token | Light | Dark |
|---|---|---|
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.
interface CategoryGroup { id: string; icon: string; color: string; label: Record<string, string> }groups (18 entries; groupById is the same list keyed by id):
| id | icon | colour | en label |
|---|---|---|---|
food | restaurant | #E8712B | Food & drink |
shopping | shopping | #D9489C | Shopping |
health | health | #E0454B | Health |
education | education | #5B6CDB | Education |
finance | bank | #2E8B57 | Finance |
transport | transport | #3A7BD5 | Transport |
auto | fuel | #5A6B7C | Auto |
lodging | hotel | #8E5AD6 | Lodging |
culture | culture | #B4631E | Culture & sights |
religion | mosque | #5F7C5A | Religion |
leisure | park | #3E9B4F | Leisure & sport |
services | services | #6B7280 | Services |
beauty | beauty | #D46AA0 | Beauty |
office | office | #7A8699 | Offices |
nature | nature | #4F8A3C | Nature |
place | place | #4B5563 | Places |
street | street | #4B5563 | Streets |
address | address | #4B5563 | Addresses |
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:
| Group | Icon names |
|---|---|
food | restaurant, cafe, fast-food, teahouse, bar, bakery, ice-cream |
shopping | supermarket, shop, mall, bazaar, clothes, electronics, furniture, books, florist, jewelry, shopping |
health | hospital, clinic, pharmacy, dentist, veterinary, health |
education | university, school, college, kindergarten, library, education |
finance | bank, atm, exchange |
transport | airport, train, metro, bus, bus-stop, taxi, transport, tram |
auto | parking, fuel, charging, car-wash, car-repair, car |
lodging | hotel |
culture | museum, theatre, cinema, gallery, attraction, monument, viewpoint, historic, zoo, culture |
religion | mosque, church, synagogue |
leisure | park, playground, stadium, sports, fitness, pool, theme-park |
services | post, police, government, embassy, fire, toilets, services |
beauty | beauty |
office | office, travel |
nature | nature, peak, water, beach |
place | place, city, village, poi |
street | street |
address | address |
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, withcolorfrom the icon's group (default#4B5563). The style references these as sprite idpoi-<name>(for examplepoi-cafe); an icon name the style cannot map falls back topoi-poiso that a missing sprite does not also drop the label.rawIconslists the hand-drawn SVGs underpackages/cartography/icons/raw/:shield-lightandshield-dark(stretchable, used for road refs),oneway,dot,dot-ring,star,peak,marker,airport-planeandentrance(SDF, tintable), andmarker-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).
| Icon | subclass values | class values |
|---|---|---|
restaurant | restaurant | restaurant |
cafe | cafe, coffee | cafe |
fast-food | fast_food, food_court | fast_food |
bar | bar, pub, biergarten | bar, beer |
ice-cream | ice_cream | ice_cream |
bakery | bakery, pastry, confectionery | bakery |
supermarket | supermarket | grocery |
shop | convenience, grocery, greengrocer, gift | shop, alcohol_shop |
mall | mall, department_store | |
bazaar | marketplace | |
clothes | clothes, shoes, boutique | clothing_store |
electronics | electronics, mobile_phone, computer | |
furniture | furniture | |
books | books, stationery | |
florist | florist | |
jewelry | jewelry | |
hospital | hospital | hospital |
clinic | clinic, doctors | doctors |
pharmacy | pharmacy | pharmacy |
dentist | dentist | dentist |
veterinary | veterinary | veterinary |
university | university | university, college |
college | college | |
school | school, language_school | school |
kindergarten | kindergarten | |
library | library | library |
bank | bank | bank |
atm | atm | atm |
exchange | bureau_de_change | |
airport | aerodrome | airport |
train | station, halt | railway |
metro | subway | |
bus | bus_station | bus |
bus-stop | bus_stop | |
tram | tram_stop | |
taxi | taxi | |
parking | parking | parking |
fuel | fuel | fuel |
charging | charging_station | |
car-wash | car_wash | |
car-repair | car_repair | |
car | car | car |
hotel | hotel, motel, hostel, guest_house | lodging, hotel |
museum | museum | museum |
theatre | theatre | theatre |
cinema | cinema | cinema |
gallery | gallery, arts_centre | art_gallery |
attraction | attraction | attraction, information |
monument | artwork, monument, memorial | monument |
viewpoint | viewpoint | |
zoo | zoo | zoo |
theme-park | theme_park | |
historic | castle, ruins, archaeological_site | castle |
mosque | place_of_worship | place_of_worship |
park | park, garden | park |
playground | playground | playground |
stadium | stadium | stadium |
sports | sports_centre, pitch | pitch, golf |
fitness | fitness_centre | |
pool | swimming_pool, water_park | swimming |
post | post_office | post |
police | police | police |
government | townhall, courthouse | town_hall |
fire | fire_station | |
embassy | embassy | embassy |
toilets | toilets | toilets |
beauty | hairdresser, beauty, cosmetics | hairdresser |
travel | travel_agency | |
services | laundry, dry_cleaning | laundry |
nature | cemetery, 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.
| Priority | Icons |
|---|---|
| 100 | airport |
| 90 | train |
| 88 | metro |
| 80 | stadium |
| 78 | university |
| 76 | hospital |
| 75 | mall |
| 74 | bazaar |
| 72 | museum, historic |
| 70 | bus, theatre |
| 68 | attraction |
| 66 | zoo, theme-park |
| 64 | park |
| 60 | monument, mosque |
| 58 | church |
| 55 | hotel, government |
| 54 | embassy |
| 52 | cinema |
| 50 | gallery, supermarket |
| 48 | bank |
| 46 | college |
| 44 | library, fuel |
| 42 | clinic |
| 40 | school, pharmacy, restaurant, teahouse, viewpoint |
| 38 | cafe |
| 36 | police, pool |
| 34 | fast-food, sports |
| 32 | bar |
| 30 | bakery, tram, charging, fitness, post |
| 28 | electronics, dentist, kindergarten, car, fire |
| 26 | ice-cream, clothes, books, exchange |
| 24 | taxi, shop, veterinary |
| 22 | furniture, jewelry, car-repair, playground, beauty |
| 20 | parking, florist, car-wash, travel |
| 18 | bus-stop, services |
| 16 | atm, office |
| 10 | toilets |
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#
UzMapOptionsdefaults (autoTiltFlatZoom12.6,autoTiltZoom15.6,tiltMax50,tiles'server',sourcestrue,maxBoundsUzbekistan,terrainUrl) are quoted from the doc comments inpackages/engine/src/types.tsand were confirmed against the constructor,detectSources()and the style URL code inpackages/engine/src/map.ts; the rest ofmap.tswas only checked for method signatures, not read in full.- The
dist/output of either package was not compared withsrc/; this page describes the source at the versions inpackage.json(@uzmaps/ui0.1.1,@uzmaps/cartography0.1.0). map.userLocationis aUserLocationwithstart(opts?: PositionOptions)andstop(), so themap.userLocation.start()call inpackages/ui/README.mdis valid; the example above still uses a no-oponLocateand the geolocation behaviour ofUserLocationwas not read.- The
lucide-reactdependency range inpackages/ui/package.jsonis^1.39.0; the locked and installed version is 1.39.0 and every iconicons.tsximports resolves in it. - Server-side behaviour of the endpoints
PlaceCarduses (/api/place/:id,/api/photo) was not read; only the client signatures inpackages/api/src/index.tswere. - 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) andexamples/ui.tsx; the workflow was not otherwise read.