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