Data Display
Maps
Declarative MapLibre: markers, clusters, routes, heatmaps, drawing and geofences. Opt-in behind omaris/map.
import { Map } from 'omaris/map' 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.
<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.
<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.
<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.
Mapwithcenterandzoom; the base style is keyless and follows the app's light and dark theme.MapControlsfor zoom, compass, locate and fullscreen buttons;MapSourceandMapLayerfor any other layer. - Things at places.
Markeris a real element with atone, asizeand a snippet (a photo, a price, an avatar);draggablelets it move.MapPopupis the card that opens on one. - Many things.
MapClustergroups thousands of points until you zoom;MapHeatmapshows where they concentrate, withweightfor the ones that count more. - Lines, areas and zones.
MapRoutefor a path;arcseparates its legs,animateshows direction.MapGeoJsonfor any geometry, coloured bycolorBy.MapDrawdraws a zone;MapGeofencefires on every crossing. - Things that move.
MapVehicledrives to each newlngLatand faces the way it goes;pathandprogressfollow a route,followkeeps the map on it.MapVehicleIconis the same glyph for a list beside the map. - Input.
MapPickerfor "put the pin on your door": the map moves under a fixed pin on touch, the pin drags with a mouse.bind:lngLat,onsettlefor the geocoder,describefor the caption.
Not for
- A picture of a place → still
Map, withinteractive={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
centerbound to the selected row. - Ten thousand
Markers →MapCluster. Markers are DOM nodes, and ten thousand of them stutter.
Do
- Import from
omaris/map, addmaplibre-glas a peer, and exclude it fromoptimizeDepsin dev. See Charts and maps. - Give it a
height; MapLibre measures the box. Leaveattributionon; the base maps require it. - Frame a set of pins with
boundsandfitPaddingrather than guessingcenterandzoom.fitPadding={{ bottom: 320 }}keeps the frame out from under a sheet. - Set
MapVehicle'sdurationto the interval positions arrive at, so it never stops between pings. UseMapPickerwhere the pin is the input; adraggableMarkeris for a pin that happens to be movable. - Draw a zone with
MapDrawand hand the polygon toMapGeofence. Both take plain GeoJSON.
Don't
- Turn
scrollZoomoff by habit. Do it only when the map really must not eat the page's scroll. - Use
MapHeatmapto count. It says where, not how many; that isMapCluster. - Build a fill, an outline and a dot layer by hand for one collection.
MapGeoJsonalready filters each to its geometry. - Add layers in
onloadwithmap.addLayer. A restyle wipes them;MapSourceandMapLayerput themselves back. - Move a car with a
MarkerwhoselngLatyou update. It teleports; useMapVehicle.
Quick reference
tone MapPickerprimary(default)secondarytertiarydestructivesuccesswarninginfo
size MapPickersmmd(default)lg
variant MapVehicleplain(default)badge
tone MapVehicleprimarysecondarytertiarydestructivesuccesswarninginfoforeground(default)
size MapVehiclesmmd(default)lg
tone Markerprimary(default)secondarytertiarydestructivesuccesswarninginfo
size Markersmmd(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 bindableDefaults to [0, 0]
LngLat [longitude, latitude]. Bindable.
zoom bindableDefaults to 2
number Bindable.
bearing bindableDefaults to 0
number Rotation in degrees. Bindable.
pitch bindableDefaults 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 bindableDefaults to undefined
Feature<Polygon> The shape. Bindable — undefined until it's closed.
drawing bindableDefaults 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 bindableLngLat 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 primarysecondarytertiarydestructivesuccesswarninginfo
size Defaults to 'md'
MapPickerSize smmdlg
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 bindableDefaults 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 plainbadge
tone Defaults to 'foreground'
MapVehicleTone primarysecondarytertiarydestructivesuccesswarninginfoforeground- The page's own foreground — the black car on the map.
size Defaults to 'md'
MapVehicleSize smmdlg
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 bindableDefaults 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 bindableLngLat [longitude, latitude]. Bindable when draggable.
tone Defaults to 'primary'
MarkerTone primarysecondarytertiarydestructivesuccesswarninginfo
size Defaults to 'md'
MarkerSize smmdlg
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.