Skip to content
Data Display

Maps

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

Walkthrough

1. 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>

2. 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>

3. 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>

4. 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>

5. 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>

6. 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>

7. 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>

8. 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>

9. 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>