Skip to content
omaris

Data Display

Maps

Declarative MapLibre: markers, clusters, routes, heatmaps, drawing and geofences. Opt-in behind omaris/map.

import { Map } from 'omaris/map'
Learn

Examples

Basic

Maps ship behind omaris/map — see Charts and maps. The base map follows the app's light or dark theme, and the tile worker is found by the component, so a production build needs nothing extra.

<script lang="ts">	import { Map, Marker, MapControls } from 'omaris/map';</script>​<Map center={[44.009, 36.191]} zoom={11} height={320} class="w-full">	<Marker lngLat={[44.009, 36.191]} pin label="Erbil" />	<MapControls /></Map>

Markers and popups

Markers and popups are components, so a list beside the map can select the same pin and open the same card.

<script lang="ts">	import { Map, Marker, MapPopup } from 'omaris/map';​	const WAREHOUSES = [		{ id: 'erbil', name: 'Erbil', at: [44.009, 36.191] as [number, number], open: 2 },		{ id: 'baghdad', name: 'Baghdad', at: [44.361, 33.312] as [number, number], open: 11 },		{ id: 'basra', name: 'Basra', at: [47.784, 30.508] as [number, number], open: 0 }	];​	let picked = $state<(typeof WAREHOUSES)[number] | null>(null);</script>​<Map center={[45.4, 33.4]} zoom={5} height={340} class="w-full">	{#each WAREHOUSES as place (place.id)}		<Marker			lngLat={place.at}			pin			tone={place.open ? 'primary' : 'destructive'}			active={picked?.id === place.id}			title={place.name}			onclick={() => (picked = place)}		/>	{/each}​	{#if picked}		<MapPopup lngLat={picked.at} offset={44} onclose={() => (picked = null)}>			<div class="flex flex-col gap-1">				<span class="text-title-sm text-foreground">{picked.name}</span>				<span class="text-body-sm text-muted-foreground">{picked.open} open orders</span>			</div>		</MapPopup>	{/if}</Map>

Styles

streets gives roads, parks and water their own colours; blank draws no base map at all — your own layers over a themed surface, which is also what the map falls back to with no network.

<script lang="ts">	import { Map, Marker } from 'omaris/map';</script>​<div class="grid w-full gap-3 sm:grid-cols-2">	<Map center={[44.009, 36.191]} zoom={10} height={220} style="streets" attribution={false}>		<Marker lngLat={[44.009, 36.191]} />	</Map>	<Map center={[44.009, 36.191]} zoom={10} height={220} style="blank" attribution={false}>		<Marker lngLat={[44.009, 36.191]} pin tone="tertiary" label="Erbil" />	</Map></div>

Clusters

Nine hundred points would be nine hundred DOM markers. MapCluster keeps them in the tile layer and groups them until you zoom in; a press on a cluster zooms to it, a press on a lone point hands you its coordinates.

Zoom in until the clusters break apart, then press a point.
<script lang="ts">	import { Map, MapCluster, MapControls, point, type LngLat } from 'omaris/map';	import { Text } from 'omaris';​	const CENTRE: LngLat = [44.009, 36.191];​	/** A spiral of orders around the city, denser near the middle. */	const ORDERS = {		type: 'FeatureCollection' as const,		features: Array.from({ length: 900 }, (_, index) => {			const angle = index * 2.399;			const spread = 0.11 * Math.sqrt(index / 900);			return point(				[CENTRE[0] + Math.cos(angle) * spread * 1.6, CENTRE[1] + Math.sin(angle) * spread],				{ total: 10 + ((index * 37) % 90) }			);		})	};​	let picked = $state<LngLat | null>(null);</script>​<div class="flex w-full flex-col">	<Map center={CENTRE} zoom={10} height={360} class="w-full">		<MapControls />		<MapCluster id="orders" data={ORDERS} onpointclick={(lngLat) => (picked = lngLat)} />	</Map>	<Text variant="label-sm" tone="muted" class="px-4 py-2 tabular-nums">		{picked			? `Order at ${picked[1].toFixed(4)}, ${picked[0].toFixed(4)}`			: 'Zoom in until the clusters break apart, then press a point.'}	</Text></div>

Routes and areas

MapRoute draws a path — arc bows each leg so out-and-back legs stay apart, animate marches the dashes along it. MapGeoJson takes any collection and colours each feature by a property. Press an area for its popup.

<script lang="ts">	import {		Map,		MapControls,		MapGeoJson,		MapPopup,		MapRoute,		Marker,		polygon,		type LngLat	} from 'omaris/map';	import { Badge, Text } from 'omaris';​	const STOPS: { name: string; at: LngLat }[] = [		{ name: 'Erbil', at: [44.009, 36.191] },		{ name: 'Kirkuk', at: [44.392, 35.468] },		{ name: 'Baghdad', at: [44.361, 33.312] },		{ name: 'Najaf', at: [44.33, 32.0] },		{ name: 'Basra', at: [47.784, 30.508] }	];​	const REGIONS = {		type: 'FeatureCollection' as const,		features: [			polygon(				[					[43.2, 35.6],					[45.4, 35.6],					[45.4, 37.2],					[43.2, 37.2]				],				{ name: 'North', couriers: 14, color: 'var(--chart-2)' }			),			polygon(				[					[43.4, 32.4],					[45.6, 32.4],					[45.6, 34.2],					[43.4, 34.2]				],				{ name: 'Centre', couriers: 31, color: 'var(--chart-4)' }			),			polygon(				[					[46.6, 29.9],					[48.4, 29.9],					[48.4, 31.2],					[46.6, 31.2]				],				{ name: 'South', couriers: 9, color: 'var(--chart-3)' }			)		]	};​	let popup = $state<{ at: LngLat; name: string; couriers: number } | null>(null);</script>​<Map center={[45.2, 33.6]} zoom={5.2} height={400} class="w-full">	<MapControls />​	<MapGeoJson		id="regions"		data={REGIONS}		colorBy="color"		fillOpacity={0.14}		interactive		onclick={(event) => {			const feature = event.features?.[0];			if (!feature) return;			popup = {				at: [event.lngLat.lng, event.lngLat.lat],				name: String(feature.properties?.name),				couriers: Number(feature.properties?.couriers)			};		}}	/>​	<MapRoute id="run" coordinates={STOPS.map((stop) => stop.at)} arc animate halo />​	{#each STOPS as stop, index (stop.name)}		<Marker			lngLat={stop.at}			pin={index === 0 || index === STOPS.length - 1}			size={index === 0 || index === STOPS.length - 1 ? 'md' : 'sm'}			tone={index === 0 ? 'primary' : index === STOPS.length - 1 ? 'tertiary' : 'secondary'}			label={stop.name}		/>	{/each}​	{#if popup}		<MapPopup lngLat={popup.at} onclose={() => (popup = null)}>			<div class="flex flex-col gap-1.5">				<Text variant="title-sm">{popup.name}</Text>				<Badge variant="tonal" size="sm">{popup.couriers} couriers on shift</Badge>			</div>		</MapPopup>	{/if}</Map>

Heatmap

Where demand concentrates, rather than how many orders there are. weight names the property that makes one point count for more; above maxZoom the blur gives way to the points themselves.

<script lang="ts">	import { Map, MapControls, MapHeatmap, point, type LngLat } from 'omaris/map';​	const CENTRE: LngLat = [44.009, 36.191];​	/** Three hot spots with some noise between them. */	const HUBS: LngLat[] = [		[44.009, 36.191],		[43.985, 36.234],		[44.041, 36.176]	];​	const DEMAND = {		type: 'FeatureCollection' as const,		features: Array.from({ length: 700 }, (_, index) => {			const hub = HUBS[index % HUBS.length];			const angle = index * 2.399;			const spread = 0.028 * Math.sqrt((index % 233) / 233);			return point([hub[0] + Math.cos(angle) * spread * 1.4, hub[1] + Math.sin(angle) * spread], {				total: 5 + ((index * 53) % 95)			});		})	};</script>​<Map center={CENTRE} zoom={11.4} height={360} style="minimalLight" class="w-full">	<MapControls />	<MapHeatmap id="demand" data={DEMAND} weight="total" maxWeight={100} radius={26} /></Map>

Draw and geofence

MapDraw lets someone draw a zone — press to add corners, press the first one to close. MapGeofence watches points against zones and reports every crossing, so the courier below announces itself as it drives out of town.

City centre
No crossings yet.
<script lang="ts">	import type { Feature, Polygon } from 'geojson';	import {		Map,		MapControls,		MapDraw,		MapGeofence,		circle,		type LngLat,		type TrackedPoint,		type Zone	} from 'omaris/map';	import { Button, Chip, Text } from 'omaris';​	const CENTRE: LngLat = [44.009, 36.191];​	let drawn = $state<Feature<Polygon> | undefined>(undefined);	let drawing = $state(false);​	const zones = $derived<Zone[]>([		{ id: 'core', name: 'City centre', polygon: circle(CENTRE, 3500) },		...(drawn ? [{ id: 'drawn', name: 'Your zone', polygon: drawn, color: 'var(--chart-3)' }] : [])	]);​	let courier = $state<TrackedPoint>({ id: 'c1', name: 'Dara', lngLat: [44.005, 36.19] });	let log = $state<string[]>([]);	let running = $state(false);​	/** Nudge the courier north-west until it is well out of the centre. */	$effect(() => {		if (!running) return;		const timer = setInterval(() => {			const [lng, lat] = courier.lngLat;			if (lat > 36.26) {				running = false;				return;			}			courier = { ...courier, lngLat: [lng - 0.0025, lat + 0.0045] };		}, 140);		return () => clearInterval(timer);	});​	function reset() {		running = false;		courier = { id: 'c1', name: 'Dara', lngLat: [44.005, 36.19] };		log = [];	}​	const note = (event: { point: TrackedPoint; zone: Zone }, verb: string) =>		(log = [`${event.point.name} ${verb} ${event.zone.name}`, ...log].slice(0, 6));</script>​<div class="flex w-full flex-col">	<div class="flex flex-wrap items-center gap-2 px-4 py-3">		<Button			size="xs"			variant={running ? 'outlined' : 'filled'}			onclick={() => (running = !running)}		>			{running ? 'Stop' : 'Send the courier out'}		</Button>		<Button size="xs" variant="tonal" onclick={() => (drawing = true)} disabled={drawing}>			{drawn ? 'Redraw a zone' : 'Draw a zone'}		</Button>		<Button size="xs" variant="text" onclick={reset}>Reset</Button>		<div class="ms-auto flex flex-wrap gap-1.5">			{#each zones as zone (zone.id)}				<Chip size="sm" variant="tonal">{zone.name}</Chip>			{/each}		</div>	</div>​	<Map center={CENTRE} zoom={10.6} height={360} class="w-full">		<MapControls />		<MapDraw bind:polygon={drawn} bind:drawing />		<MapGeofence			{zones}			points={[courier]}			onenter={(event) => note(event, 'entered')}			onleave={(event) => note(event, 'left')}		/>	</Map>​	<div class="flex min-h-10 flex-col gap-0.5 px-4 py-2">		{#if log.length === 0}			<Text variant="label-sm" tone="muted">No crossings yet.</Text>		{:else}			{#each log as entry, index (index)}				<Text variant="label-sm" tone={index === 0 ? undefined : 'muted'}>{entry}</Text>			{/each}		{/if}	</div></div>

Location picker

MapPicker is "put the pin on your door". On a touchscreen the pin stays in the middle and the map moves under it; with a mouse the pin is dragged, or a click moves it. describe turns the point into words once it settles.

36.19100, 44.00900

<script lang="ts">	import { Map, MapControls, MapPicker, distance, type LngLat } from 'omaris/map';	import { Button, SegmentedButton, Text } from 'omaris';​	const LANDMARKS: { name: string; at: LngLat }[] = [		{ name: 'the Citadel', at: [44.0094, 36.1911] },		{ name: 'Sami Abdulrahman Park', at: [43.9855, 36.1875] },		{ name: 'Family Mall', at: [44.0296, 36.2116] }	];​	let at = $state<LngLat>([44.009, 36.191]);	let mode = $state<'auto' | 'center' | 'drag'>('auto');	let confirmed = $state<string | null>(null);​	/** A stand-in for a geocoder: the nearest thing we know, and how far. */	function describe(point: LngLat) {		const nearest = LANDMARKS.reduce((best, mark) =>			distance(point, mark.at) < distance(point, best.at) ? mark : best		);		return `${Math.round(distance(point, nearest.at))} m from ${nearest.name}`;	}</script>​<div class="flex flex-col gap-3">	<Map center={[44.009, 36.191]} zoom={14} height={360} class="w-full">		<MapControls />		<MapPicker bind:lngLat={at} {mode} {describe} onsettle={() => (confirmed = null)} />	</Map>​	<div class="flex flex-wrap items-center gap-3">		<SegmentedButton			size="sm"			label="Mode"			value={mode}			onchange={(value) => (mode = value as typeof mode)}			items={[				{ value: 'auto', label: 'Auto' },				{ value: 'center', label: 'Centre pin' },				{ value: 'drag', label: 'Drag pin' }			]}		/>		<Button size="sm" onclick={() => (confirmed = describe(at))}>Confirm pickup</Button>		<Text variant="body-sm" tone="muted" class="tabular-nums">			{confirmed ?? `${at[1].toFixed(5)}, ${at[0].toFixed(5)}`}		</Text>	</div></div>

Vehicles

MapVehicle drives to each new position instead of jumping, turning to face the way it goes. Give it a path and progress and it follows the route; follow keeps the map on it until the map is dragged away. Every model is also MapVehicleIcon, for a list beside the map.

Progress
<script lang="ts">	import {		Map,		MapControls,		MapRoute,		MapVehicle,		MapVehicleIcon,		Marker,		VEHICLE_MODELS,		type LngLat,		type VehicleModel	} from 'omaris/map';	import { Button, Chip, ChipGroup, Slider } from 'omaris';​	const ROUTE: LngLat[] = [		[44.0094, 36.1911],		[44.0175, 36.1968],		[44.0212, 36.2071],		[43.9855, 36.234]	];​	let model = $state<VehicleModel>('taxi');	let progress = $state(0.15);	let driving = $state(false);	let follow = $state(false);​	/** A courier whose pings arrive every two seconds. */	let courier = $state<LngLat>([44.03, 36.176]);	let tick = 0;​	$effect(() => {		const timer = setInterval(() => {			tick += 1;			courier = [44.03 + Math.sin(tick / 3) * 0.012, 36.176 + Math.cos(tick / 4) * 0.008];		}, 2000);		return () => clearInterval(timer);	});​	$effect(() => {		if (!driving) return;		const timer = setInterval(() => {			progress = progress >= 1 ? 0 : Math.min(1, progress + 0.01);		}, 100);		return () => clearInterval(timer);	});</script>​<div class="flex flex-col gap-3 px-4">	<Map center={[44.005, 36.2]} zoom={12} height={380} class="w-full">		<MapControls />		<MapRoute id="demo-route" coordinates={ROUTE} color="var(--primary)" width={4} />		<Marker lngLat={ROUTE[0]} tone="primary" size="sm" />		<Marker lngLat={ROUTE[ROUTE.length - 1]} pin tone="tertiary" />​		<!-- Along a route, by progress. -->		<MapVehicle			{model}			path={ROUTE}			{progress}			duration={100}			tone="primary"			size="lg"			pulse={driving}			bind:follow			label="You"		/>​		<!-- From a stream of positions, two seconds apart. -->		<MapVehicle			model="motorcycle"			lngLat={courier}			duration={2000}			variant="badge"			tone="tertiary"		/>	</Map>​	<ChipGroup overflow="scroll" label="Model">		{#each VEHICLE_MODELS as entry (entry)}			<Chip size="sm" selectable selected={model === entry} onclick={() => (model = entry)}>				{#snippet start()}<MapVehicleIcon model={entry} class="size-4" />{/snippet}				{entry}			</Chip>		{/each}	</ChipGroup>​	<div class="flex flex-wrap items-center gap-3">		<Button size="sm" variant={driving ? 'tonal' : 'filled'} onclick={() => (driving = !driving)}>			{driving ? 'Pause' : 'Drive'}		</Button>		<Button size="sm" variant="outlined" onclick={() => (follow = !follow)}>			{follow ? 'Stop following' : 'Follow'}		</Button>		<Slider			bind:value={progress}			min={0}			max={1}			step={0.01}			label="Progress"			class="min-w-48 flex-1"		/>	</div></div>

When to use it

Use it for

  • A place. Map with center and zoom; the base style is keyless and follows the app's light and dark theme. MapControls for zoom, compass, locate and fullscreen buttons; MapSource and MapLayer for any other layer.
  • Things at places. Marker is a real element with a tone, a size and a snippet (a photo, a price, an avatar); draggable lets it move. MapPopup is the card that opens on one.
  • Many things. MapCluster groups thousands of points until you zoom; MapHeatmap shows where they concentrate, with weight for the ones that count more.
  • Lines, areas and zones. MapRoute for a path; arc separates its legs, animate shows direction. MapGeoJson for any geometry, coloured by colorBy. MapDraw draws a zone; MapGeofence fires on every crossing.
  • Things that move. MapVehicle drives to each new lngLat and faces the way it goes; path and progress follow a route, follow keeps the map on it. MapVehicleIcon is the same glyph for a list beside the map.
  • Input. MapPicker for "put the pin on your door": the map moves under a fixed pin on touch, the pin drags with a mouse. bind:lngLat, onsettle for the geocoder, describe for the caption.

Not for

  • A picture of a place → still Map, with interactive={false}.
  • A distribution with no geography → Chart.
  • Choosing a country or a city → Combobox.
  • A list of addresses → List or Table, with the map beside it and center bound to the selected row.
  • Ten thousand Markers → MapCluster. Markers are DOM nodes, and ten thousand of them stutter.

Do

  • Import from omaris/map, add maplibre-gl as a peer, and exclude it from optimizeDeps in dev. See Charts and maps.
  • Give it a height; MapLibre measures the box. Leave attribution on; the base maps require it.
  • Frame a set of pins with bounds and fitPadding rather than guessing center and zoom. fitPadding={{ bottom: 320 }} keeps the frame out from under a sheet.
  • Set MapVehicle's duration to the interval positions arrive at, so it never stops between pings. Use MapPicker where the pin is the input; a draggable Marker is for a pin that happens to be movable.
  • Draw a zone with MapDraw and hand the polygon to MapGeofence. Both take plain GeoJSON.

Don't

  • Turn scrollZoom off by habit. Do it only when the map really must not eat the page's scroll.
  • Use MapHeatmap to count. It says where, not how many; that is MapCluster.
  • Build a fill, an outline and a dot layer by hand for one collection. MapGeoJson already filters each to its geometry.
  • Add layers in onload with map.addLayer. A restyle wipes them; MapSource and MapLayer put themselves back.
  • Move a car with a Marker whose lngLat you update. It teleports; use MapVehicle.

Quick reference

tone MapPicker
  • primary (default)
  • secondary
  • tertiary
  • destructive
  • success
  • warning
  • info
size MapPicker
  • sm
  • md (default)
  • lg
variant MapVehicle
  • plain (default)
  • badge
tone MapVehicle
  • primary
  • secondary
  • tertiary
  • destructive
  • success
  • warning
  • info
  • foreground (default)
size MapVehicle
  • sm
  • md (default)
  • lg
tone Marker
  • primary (default)
  • secondary
  • tertiary
  • destructive
  • success
  • warning
  • info
size Marker
  • sm
  • md (default)
  • lg

API

Map

Interactive map.

That's a working map — the base style is keyless, so there's no account to make and no token to leak. It follows the theme by default, swapping to the dark base map when the app does.

Everything else in the map family is a child of this one and finds the map through context, so a marker is <Marker lngLat={…} /> and a layer is <MapLayer …/> — no imperative map.addLayer in your page, and nothing to tear down by hand.

## Gestures

The wheel zooms and one finger pans, the way every map anyone has used behaves. There is no modifier to hold and no "use two fingers" scrim: MapLibre's cooperative-gesture mode buys back the page's scroll at the cost of making the map feel broken, and a map that ignores the first gesture aimed at it is worse than a page that needs one flick around it.

If a map really must not eat the page's scroll, give it scrollZoom={false} — the buttons in <MapControls> still zoom — or interactive={false} for a picture of a place.

import { Map } from 'omaris/map'
<Map center={[44.0, 36.19]} zoom={11} />

Props

center bindable

Defaults to [0, 0]

LngLat

[longitude, latitude]. Bindable.

zoom bindable

Defaults to 2

number

Bindable.

bearing bindable

Defaults to 0

number

Rotation in degrees. Bindable.

pitch bindable

Defaults to 0

number

Tilt in degrees. Bindable.

bounds
MapBounds

Frame these bounds instead of using center/zoom.

fitPadding

Defaults to 40

number | Partial<MapPadding>

Padding, in px, kept around bounds — one number for all four sides, or a side or two of their own: { bottom: 320 } frames the trip in the strip of map a sheet leaves visible. A side not named keeps the default 40.

style
MapStyleName | 'blank' | string | StyleSpecification

A name from MAP_STYLES, 'blank', a URL, or a full style object.

theme

Defaults to 'auto'

'light' | 'dark' | 'auto'

auto follows the app's light/dark setting.

minZoom
number
maxZoom
number
maxBounds
MapBounds

Keep the viewport inside these bounds.

interactive

Defaults to true

boolean

Turn off every interaction — a static picture of a place.

rotatable

Defaults to true

boolean

Let a drag rotate and tilt the map.

scrollZoom

Defaults to true

boolean

The wheel zooms.

height

Defaults to 400

number | string

Height of the map. A number is px; a string is any CSS length.

attribution

Defaults to true

boolean

Show the attribution the base maps require. Leave it on.

cursor
string

Cursor over the map.

onload
(map: MapLibreMap) => void
onmove
(viewport: MapViewport) => void
onmoveend
(viewport: MapViewport) => void
onclick
(lngLat: LngLat, event: MapMouseEvent) => void
oncontextmenu
(lngLat: LngLat, event: MapMouseEvent) => void
class
string
children
Snippet

Markers, layers, controls — anything that needs the map.

overlay
Snippet

Drawn over the map, outside its own event handling.

MapCluster

Thousands of points, grouped.

Ten thousand markers is ten thousand DOM nodes and a map that stutters; the same points as a clustered source stay on the GPU and don't. A press on a cluster zooms to the extent it covers, which is what makes the pile navigable rather than merely tidy.

Clusters grow with their count in steps rather than continuously, so a glance tells you the order of magnitude.

import { MapCluster } from 'omaris/map'
<MapCluster id="stops" data={stops} />

Props

id required
string
data required
GeoJsonInput
radius

Defaults to 50

number

How near two points have to be to group, in px.

maxZoom

Defaults to 14

number

Above this zoom every point stands alone.

color

Defaults to 'var(--chart-1)'

string

Any CSS colour, for the clusters and the lone points.

steps

Defaults to [10, 50]

[number, number]

Counts at which a cluster steps up a size.

pointRadius

Defaults to 6

number

Radius of a point that isn't in a cluster, in px.

onclick
(event: MapLayerMouseEvent) => void
onpointclick
(lngLat: LngLat, event: MapLayerMouseEvent) => void

A press on a single point, with its coordinates.

MapControls

Zoom, compass, locate and fullscreen — as omaris buttons rather than MapLibre's, so they carry the app's radii, shadows and focus rings and sit at a real touch target size.

The compass only appears once the map is rotated, because a control that does nothing is a control in the way.

import { MapControls } from 'omaris/map'
<Map>  <MapControls locate fullscreen /></Map>

Props

position

Defaults to 'top-end'

'top-start' | 'top-end' | 'bottom-start' | 'bottom-end'

Corner it sits in.

zoom

Defaults to true

boolean
compass

Defaults to true

boolean

Reset north. Hidden while the map is already facing north.

locate

Defaults to false

boolean

Jump to the visitor's position.

fullscreen

Defaults to false

boolean
scale

Defaults to false

boolean

A bar showing what the current scale means on the ground.

onlocate
(lngLat: LngLat) => void

Called with the located position, so a caller can drop a marker.

onlocateerror
(error: GeolocationPositionError) => void
class
string
children
Snippet

Extra buttons, below the built-in ones.

MapDraw

Draw a territory on the map.

Tap to drop a corner, tap the first one again — or press Finish — to close the shape. Drag a corner to move it, drag the midpoint between two to add one, and press a corner's × to take it out.

Built for a finger first: the handles are real touch targets rather than 6px canvas dots, and everything is a tap rather than a click-and-hold, because a long press on a map means something else.

The shape is a plain GeoJSON polygon, which is what <MapGeofence> and pointInPolygon take, so drawing a delivery zone and watching for couriers leaving it are the same two lines apart.

import { MapDraw } from 'omaris/map'
<MapDraw bind:polygon onfinish={save} />

Props

polygon bindable

Defaults to undefined

Feature<Polygon>

The shape. Bindable — undefined until it's closed.

drawing bindable

Defaults to false

boolean

Start in drawing mode. Bindable.

editable

Defaults to true

boolean

Let existing corners be moved, added and removed.

color

Defaults to 'var(--chart-1)'

string

Any CSS colour.

id

Defaults to 'omaris-draw'

string

Prefix for the layers this adds.

onfinish
(polygon: Feature<Polygon>) => void

Fires when the shape closes.

onchange
(points: LngLat[]) => void

Fires on every change, including while drawing.

onclear
() => void

Fires when the shape is cleared.

class
string

MapGeofence

Watch points against zones and say when one crosses.

The zones draw themselves, each in its own colour, and every time points changes the component works out which of them moved in or out of which zone since the last update — so a live feed of positions becomes a stream of enter and leave events without any bookkeeping in the page.

Membership is settled by ray casting on plain coordinates, which is exact for any zone drawn on a screen. Nothing is polled and nothing is timed: an event fires when a position changes, and only then.

import { MapGeofence } from 'omaris/map'
<MapGeofence  zones={territories}  points={couriers}  onleave={(event) => toast.warning(`${event.point.name} left ${event.zone.name}`)}/>

Props

zones required
Zone[]
points

Defaults to []

TrackedPoint[]

The points being watched. Update it and the crossings are reported.

id

Defaults to 'omaris-geofence'

string

Prefix for the layers this adds.

show

Defaults to true

boolean

Draw the zones. Off when something else already draws them.

showPoints

Defaults to true

boolean

Draw the points too.

fillOpacity

Defaults to 0.14

number

Fill opacity of each zone.

onenter
(event: GeofenceEvent) => void

A point crossed into a zone.

onleave
(event: GeofenceEvent) => void

A point crossed out of one.

onchange
(inside: Map<string, string[]>) => void

Which zones hold each point, on every change.

marker
Snippet<[TrackedPoint, string[]]>

Rendered for each point, in place of the default dot.

MapGeoJson

Draw GeoJSON, whatever's in it.

One component covers the case that otherwise needs a source and three layers: polygons get a fill and an outline, lines get a stroke, points get dots — each filtered to the geometry it belongs to, so a mixed collection draws correctly without being split up first.

Colour follows the theme by default. Give it a color for one shade throughout, or a colorBy property name to colour by a field.

import { MapGeoJson } from 'omaris/map'
<MapGeoJson id="districts" data={districts} />

Props

id required
string
data required
GeoJsonInput
color

Defaults to 'var(--chart-1)'

string | unknown[]

Any CSS colour, or a MapLibre expression.

colorBy
string

Colour each feature by this property, which must hold a colour.

fillOpacity

Defaults to 0.18

number

Fill opacity for polygons.

lineWidth

Defaults to 2

number

Stroke width for lines and polygon outlines.

pointRadius

Defaults to 5

number

Radius of point features, in px.

dash
number[]

Dash the lines — [2, 2] and so on.

interactive

Defaults to false

boolean

Show a pointer and respond to presses.

before
string

Insert below this layer id, to sit under the base map's labels.

visible

Defaults to true

boolean
onclick
(event: MapLayerMouseEvent) => void
onhover
(event: MapLayerMouseEvent | null) => void

MapHeatmap

Density, as heat.

For the question clustering can't answer: not "how many points are here" but "where is this concentrated". The ramp runs from transparent through the chart roles, so it reads on both base maps, and it fades out as you zoom in — past a certain point the individual features are the better answer, and <MapCluster> or a circle layer takes over.

import { MapHeatmap } from 'omaris/map'
<MapHeatmap id="orders" data={orders} weight="total" />

Props

id required
string
data required
GeoJsonInput
weight
string

Property whose value weights each point. Defaults to one each.

maxWeight

Defaults to 1

number

Largest expected value of weight, for scaling.

radius

Defaults to 24

number

Blur radius, in px.

intensity

Defaults to 1

number
opacity

Defaults to 0.8

number
maxZoom

Defaults to 16

number

Stop drawing above this zoom, where points are better.

before
string
visible

Defaults to true

boolean

MapLayer

A style layer over a source.

It finds its source from the <MapSource> around it, re-adds itself after a restyle, and turns MapLibre's per-layer event API into ordinary props — including the pointer cursor, which is the bit everyone forgets and which is the difference between a layer that looks clickable and one that doesn't.

import { MapLayer } from 'omaris/map'
<MapLayer id="zones" type="fill" paint={{ 'fill-color': '#2563eb' }} />

Props

id required
string
type required
LayerSpecification['type']
source
string

Defaults to the enclosing <MapSource>.

sourceLayer
string

Layer within a vector source.

paint
Record<string
layout
Record<string
filter
FilterSpecification
minZoom
number
maxZoom
number
before
string

Insert below this layer, to sit under the labels.

visible

Defaults to true

boolean

Hide without removing.

interactive

Defaults to false

boolean

Show a pointer over the layer's features.

onclick
(event: MapLayerMouseEvent) => void
onhover
(event: MapLayerMouseEvent | null) => void

MapPicker

"Put the pin where you are."

Two ways to choose a point, and the picker knows which one fits:

- On a touchscreen the pin stays fixed in the middle and the map moves under it. A finger that drags a pin covers it, and a map that pans is the gesture everyone already knows; the pin lifts while the map is moving and settles when it stops. - With a mouse the pin sits on the map and is dragged — or moved with a click on the spot. A mouse is precise and covers nothing.

mode forces either. Either way lngLat is bindable, onchange fires as it moves and onsettle once it has stopped — which is when to ask a geocoder for the address. Hand that in as describe and the picker shows it in a caption over the pin.

Arrow keys nudge the pin with the keyboard, in both modes.

import { MapPicker } from 'omaris/map'
<Map center={here} zoom={15}>  <MapPicker bind:lngLat={pickup} /></Map>

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
No description in the source yet.
pin
The pin, tip on the point.
caption
The address over the pin.
control
The focusable control the pin lives in.

Props

lngLat bindable
LngLat

The chosen point. Bindable; starts at the map's centre when omitted.

mode

Defaults to 'auto'

MapPickerMode

center fixes the pin in the middle and moves the map; drag moves the pin. auto is center on a coarse pointer and drag on a fine one.

tone

Defaults to 'primary'

MapPickerTone
primary
secondary
tertiary
destructive
success
warning
info
size

Defaults to 'md'

MapPickerSize
sm
md
lg
describe
string | ((lngLat: LngLat) => string | Promise<string>)

Text for the caption — a string, or a function of the point that may return a promise. Called once the pin settles.

clickToMove

Defaults to true

boolean

In drag mode, a click on the map moves the pin there. On by default.

nudge

Defaults to 8

number

Pixels an arrow key moves the pin.

disabled

Defaults to false

boolean

Show the pin but take no input.

title

Defaults to 'Location'

string

Accessible name of the pin.

onchange
(lngLat: LngLat) => void

Fires with every movement, mid-gesture included.

onsettle
(lngLat: LngLat) => void

Fires once the pin has come to rest.

z
number

Stacking order against markers, in drag mode.

class
string
classes
MapPickerClasses

Per-part Tailwind overrides. class still covers the root.

children
Snippet

Replaces the pin. Its bottom edge is the point.

MapPopup

A card anchored to a place on the map.

MapLibre positions it — including keeping it on screen near the edges — and everything inside is ordinary markup, so it takes the app's own type and spacing rather than a map library's.

import { MapPopup } from 'omaris/map'
<MapPopup bind:open lngLat={selected}>  <p class="font-medium">Warehouse 4</p></MapPopup>

Props

lngLat required
LngLat

[longitude, latitude] it points at.

open bindable

Defaults to true

boolean

Bindable.

anchor
| 'center'

Which side of the coordinate it sits on. Auto-placed when unset.

offset

Defaults to 14

number

Distance from the coordinate, in px. The default clears a dot; a pin marker stands about 44px above its point, so a popup on one wants offset={44} or it sits on the pin's head.

closeButton

Defaults to true

boolean

Show the ×.

closeOnClick

Defaults to true

boolean

A press on the map closes it.

focus

Defaults to true

boolean

Pan the map so the popup fits.

maxWidth

Defaults to '18rem'

string
onclose
() => void
class
string
children
Snippet

MapRoute

A path between places.

arc bends each leg into a curve, which is what makes a point-to-point map (a flight, a shipment) legible: two straight lines between the same three cities overlap, two arcs don't.

animate marches the dashes along the line, so direction is visible without an arrowhead.

import { MapRoute } from 'omaris/map'
<MapRoute id="delivery" coordinates={stops} arc />

Props

id required
string
coordinates required
LngLat[]

The path, in order.

color

Defaults to 'var(--chart-1)'

string

Any CSS colour.

width

Defaults to 3

number
arc

Defaults to false

boolean

Bend each leg instead of drawing it straight.

bend

Defaults to 0.2

number

How far each arc bows out, as a fraction of the leg's length.

dash
number[]

Dash pattern, e.g. [2, 2].

animate

Defaults to false

boolean

March the dashes along the line. Implies dash.

halo

Defaults to true

boolean

A soft wider line under the main one, so it reads over any base map.

opacity

Defaults to 1

number
before
string
visible

Defaults to true

boolean

MapSource

A GeoJSON source, declared rather than added.

It survives a restyle — changing the base map throws away every source and layer, and this puts them back — and it updates in place when the data changes, which is what keeps a live feed from flickering and what lets a drawing tool redraw on every corner.

import { MapSource } from 'omaris/map'
<MapSource id="stops" data={stops}>  <MapLayer id="stops-dots" type="circle" paint={{ 'circle-radius': 5 }} /></MapSource>

Props

id required
string

Unique within the map. Layers refer to it by this.

data required
GeoJsonInput
cluster

Defaults to false

boolean

Group nearby points. See <MapCluster> for the ready-made version.

clusterRadius

Defaults to 50

number
clusterMaxZoom

Defaults to 14

number
clusterProperties
Record<string

Sums and counts to compute per cluster.

tolerance
number

Simplification tolerance. Lower is more faithful and heavier.

children
Snippet

Layers reading from this source.

MapVehicle

Something that moves: a car, a courier, a bus, a boat.

Give it a new lngLat and it drives there rather than teleporting — easing across over duration, and turning to face the way it is going. Set duration to the interval positions arrive at (a ping every two seconds → duration={2000}) and the vehicle never stops between them.

With a path, progress says how far along it is, by distance, and the heading comes from the leg it is on. follow keeps the map centred on it until the person drags the map away; bind it, and a "recentre" button is one line.

A plain vehicle sits straight on the map with its shadow under it, the way a ride app draws the cars around you; a badge puts the same car in a tinted circle, which reads better in a crowd or over a busy basemap.

import { MapVehicle } from 'omaris/map'
<MapVehicle model="taxi" lngLat={driver} />
<MapVehicle model="car" path={route} progress={0.4} />

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
No description in the source yet.
body
The clickable, tinted part — everything but the label.
glyph
The rotating glyph.
pulse
The ring that breathes under a live vehicle.
label
Text under the vehicle. Stays upright whatever the heading.

Props

model

Defaults to 'car'

VehicleModel
lngLat
LngLat

Where it is. Each change is driven to, not jumped to.

path
LngLat[]

Follow this path instead of lngLat; progress says how far.

progress

Defaults to 0

number

0 → 1 along path, by distance.

heading
number

Degrees clockwise from north. Worked out from the movement when omitted.

duration

Defaults to 600

number

How long a move takes, in ms — the interval positions arrive at.

variant

Defaults to 'plain'

MapVehicleVariant
plain
badge
tone

Defaults to 'foreground'

MapVehicleTone
primary
secondary
tertiary
destructive
success
warning
info
foreground
The page's own foreground — the black car on the map.
size

Defaults to 'md'

MapVehicleSize
sm
md
lg
active

Defaults to false

boolean

Highlight it — the one that is yours.

pulse

Defaults to false

boolean

A ring breathing under it: live, and being tracked.

follow bindable

Defaults to false

boolean

Keep the map centred on it. Bindable: a drag on the map turns it off, so a "recentre" button is onclick={() => (follow = true)}.

label
string

Short text under it.

z
number

Stacking order against the other markers.

title
string

Accessible name. Falls back to label, then to the model.

onclick
(lngLat: LngLat) => void
onarrive
(lngLat: LngLat, heading: number) => void

Fires when a move finishes — with where it is and which way it faces.

class
string
classes
MapVehicleClasses

Per-part Tailwind overrides. class still covers the root.

children
Snippet

Replaces the glyph. Still rotated to the heading.

MapVehicleIcon

One of the vehicles on its own — for a list of ride tiers, a legend, a chip — drawn exactly as <MapVehicle> draws it on the map.

The body takes currentColor, so text-primary paints the car and the shading follows. Facing north by default; heading turns it.

import { MapVehicleIcon } from 'omaris/map'
<MapVehicleIcon model="taxi" class="size-8" />

Props

model

Defaults to 'car'

VehicleModel
heading

Defaults to 0

number

Degrees clockwise from north.

shadow

Defaults to true

boolean

Draw the soft shadow on the ground. Off for an icon in a list.

class
string

Marker

A point on the map.

The default pin is a real element rather than a canvas sprite, so it takes tones, sizes and a class like everything else — and its content can be anything: a snippet turns it into a photo, a price, an avatar.

draggable makes lngLat bindable, which is how you let someone place a delivery address by dragging it.

import { Marker } from 'omaris/map'
<Marker lngLat={[44.0, 36.19]} label="Warehouse" />

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
No description in the source yet.
label
Text riding beside the pin.

Props

lngLat required bindable
LngLat

[longitude, latitude]. Bindable when draggable.

tone

Defaults to 'primary'

MarkerTone
primary
secondary
tertiary
destructive
success
warning
info
size

Defaults to 'md'

MarkerSize
sm
md
lg
pin

Defaults to false

boolean

Draw a teardrop pin instead of a dot.

active

Defaults to false

boolean

Highlight it — the selected one in a list, say.

label
string

Short text under the pin.

anchor

Defaults to pin ? 'bottom' : 'center'

| 'center'

Which part of the element sits on the coordinate.

draggable

Defaults to false

boolean

Let it be dragged. lngLat follows.

z
number

Stacking order against the other markers.

title
string

Accessible name. Falls back to label.

onclick
(lngLat: LngLat) => void
ondragend
(lngLat: LngLat) => void
class
string
classes
MarkerClasses

Per-part Tailwind overrides. class still covers the root.

children
Snippet

Replaces the pin entirely.