Map tools
Each tool works on the map it sits in and is built on your own shadcn primitives. A tool that calls the API, such as the elevation profile, makes one metered request per call.
npx shadcn@latest registry add @unmap=https://registry.unmap.dev/r/{name}.json
npx shadcn@latest add @unmap/measurenpx shadcn-vue@latest add https://registry.unmap.dev/r/vue/measure.jsonThe React line registers the registry once per project, so later items can be named @unmap/<item>; shadcn-vue has no registry add and takes the item URL directly. Files land in components/ui/ beside your own; nothing is overwritten without asking.
Browse every item on registry.unmap.dev| Item | What it is |
|---|---|
measure | Click the map to measure a distance along the great circle, with a live label on the map and Undo and Clear on your Button. Metres and kilometres, in English or French. |
feature-inspector | The features under a click, or in view, listed in your Card and Table. Click a row to outline the feature and read its properties. |
time-slider | Your Slider driving a time window on map layers, with play, pause, step and loop. Works next to a layer-legend on the same layer. |
draw | Draw and edit points, lines, polygons and circles on your ToggleGroup, Button and Tooltip. The shapes come back as GeoJSON and survive a style swap. |
compare-swipe | Two maps in one frame with a draggable divider and one shared camera: before and after, or one style against another. |
elevation-profile | The ground elevation along a route, a drawn line or your own coordinates, on your Chart, with the distance, gain, loss, lowest and highest point. Hover the chart or the line and the other follows. |
Measure distances
measure adds a point wherever the reader clicks and measures the line through the points along
the great circle, the way the Earth is shaped: Vancouver to Halifax reads about 4,430 km. While the
pointer is over the map a dashed line runs to it, and the label on the map shows the total so far.
The line is drawn on top of the map in your --primary, with Undo and Clear on your shadcn Button:
import { Map } from "@/components/ui/map";
import { Measure } from "@/components/ui/measure";
<Map apiKey={key} center={[-114.07, 51.05]} zoom={11}>
<Measure onChange={({ points, metres }) => console.log(points.length, metres)} />
</Map>Try it, then add it as a starting point:
React projects. Installs components/unmap-map-measure.tsx; render <MapMeasure apiKey={key} />.
- Distances under 1 km are in metres; above, in kilometres with two, one or no decimals as the
number grows.
lang="fr"writes them the French way,1,25 kmand4 430 km, with a no-break space between thousands and before the unit, and names the buttons in French.formatreplaces the formatter. - A double-click or double-tap adds one point, not two, and does not zoom: the map's double-click zoom is paused while measuring. Undo removes the last point and Clear removes them all. With the map focused, Escape hides the dashed line until the pointer moves, and Backspace undoes the last point.
- A line that crosses the antimeridian (180 degrees of longitude) stays one continuous line.
onChange(Vue:@change) receives the points and their length in metres after each change.active={false}(Vue::active="false") stops adding points and keeps the measurement on the map.- The measurement survives a style swap and comes off the map when the component unmounts. Its layers are marked as a tool's own, so the feature inspector never lists them. Measuring happens in the browser and makes no request.
- The inspector and the measure tool both answer map clicks. Run one at a time, and switch the
other off with
enabled={false}oractive={false}. - It renders a bare row: wrap it in your own Card when it floats over the map.
<script setup lang="ts">
import { ref } from "vue";
import { UnmapMap } from "@/components/ui/map";
import { UnmapMeasure } from "@/components/ui/measure";
const apiKey = import.meta.env.VITE_UNMAP_KEY;
const metres = ref(0);
</script>
<template>
<UnmapMap :api-key="apiKey" :center="[-114.07, 51.05]" :zoom="11">
<UnmapMeasure lang="fr" @change="metres = $event.metres" />
</UnmapMap>
</template>Inspect features
feature-inspector lists what the map drew under a click, in your shadcn Card and Table: every
feature within a few pixels, topmost first, with the layer that drew it. The topmost is selected,
its properties are listed, and its shape is outlined on the map in your --primary. Click a row to
select another feature, or the selected one to clear it. The list and the properties are two
tables, each named by the region around it, so a screen reader announces them:
import { Map } from "@/components/ui/map";
import { FeatureInspector } from "@/components/ui/feature-inspector";
<Map apiKey={key} center={[-75.69, 45.42]} zoom={14}>
<FeatureInspector className="absolute top-2 left-2 w-80" onSelect={(feature) => console.log(feature)} />
</Map>Try it, then add it as a starting point:
React projects. Installs components/unmap-map-inspect.tsx; render <MapInspect apiKey={key} />.
- By default every layer is queried: the basemap's roads, places and water, and your own data.
layerslimits it to some style layer ids; one ending in*matches every layer that starts with the rest, and an id the style does not have is skipped. The tools' own layers, such as a measurement or the outline itself, are never listed. scope="view"lists what is drawn in the whole view instead, again after every move, up tolimitrows (50 by default). A selection is kept across those refreshes.- A feature cut across tiles is listed once. Features without an id that carry the same properties in the same layer collapse into one row.
- With an id, the outline is the whole feature from every loaded tile. Polygons are filled, not
stroked, so no tile seam is drawn through them; the pieces are drawn per tile and may overlap
faintly at tile edges. Without an id, or from a source that promotes a property to the id
(
promoteId), the outline is the piece drawn where you clicked. - The inspector reads what the map already drew, so it makes no request.
enabled={false}(Vue::enabled="false") ignores clicks and clears the list, the selection and the outline.lang="fr"writes the card in French and names features byname:frwhen they carry one. The outline survives a style swap; unmounting removes it.onSelect(Vue:@select) receives the selected feature, ornull. - The outline is computed when you select a feature, so it stays drawn if a slider or legend later filters that feature out. Click the row again to refresh it.
- The inspector and the
measuretool both answer map clicks. Run one at a time.
<script setup lang="ts">
import { UnmapMap } from "@/components/ui/map";
import { UnmapFeatureInspector } from "@/components/ui/feature-inspector";
const apiKey = import.meta.env.VITE_UNMAP_KEY;
</script>
<template>
<UnmapMap :api-key="apiKey" :center="[-75.69, 45.42]" :zoom="14">
<UnmapFeatureInspector class="absolute top-2 left-2 w-80" scope="view" :limit="20" />
</UnmapMap>
</template>Time slider
time-slider puts your shadcn Slider over a number on your features, a year or a time in
milliseconds, and shows only the features in the window it selects, with play, pause and step
buttons:
import { Map } from "@/components/ui/map";
import { GeoJSONLayer } from "@/components/ui/geojson-layer";
import { TimeSlider } from "@/components/ui/time-slider";
<Map apiKey={key} center={[-114.07, 51.05]} zoom={6}>
<GeoJSONLayer id="wells" data={wells} />
<TimeSlider layers={["wells-*"]} property="year" min={1950} max={2025} interval={500} loop />
</Map>Try it, then add it as a starting point:
React projects. Installs components/unmap-map-timeline.tsx; render <MapTimeline apiKey={key} />.
- A single value shows one step:
2001withstep={1}shows the features whoseyearis at least 2001 and under 2002.cumulativeshows everything before the end of the step instead. A pair, such asdefaultValue={[1990, 2000]}, shows the features between the two, both ends included, and keeps its width when it plays. - Play moves one step every
intervalmilliseconds (1,000 by default) and stops at the end;loopstarts over instead. Pressing play at the end starts from the beginning. - The state is uncontrolled with
defaultValue, or controlled withvalueandonValueChange(Vue:v-model).formatwrites the label, for example a date from milliseconds. - The window goes on top of each layer's own filter, in the same syntax, next to the values a
layer-legendunchecks on the same layer: they share one per-map store of filter clauses, so neither undoes the other and the clauses never nest. Features without the property are hidden while the slider applies. It is applied again after a style swap and when a named layer is added later. - Unmounting takes the window off, so no feature stays hidden without a slider to bring it back. Filtering happens in the browser: a step is not a request.
- Values are numbers, a year or milliseconds since 1970. On an expression filter (or none) a year stored as text still compares; on a legacy-syntax filter it never matches. ISO date strings are not supported.
- A controlled value must be fed back from
onValueChange(Vue:update:modelValue, sov-model) or nothing moves and play never advances. - In Vue, forward the label to each handle in your
Slider.vue, for example<SliderThumb :aria-labelledby="$attrs['aria-labelledby']" />, because reka-ui does not name the thumbs from the root.
<script setup lang="ts">
import { ref } from "vue";
import { UnmapMap } from "@/components/ui/map";
import { UnmapGeojsonLayer } from "@/components/ui/geojson-layer";
import { UnmapTimeSlider } from "@/components/ui/time-slider";
const apiKey = import.meta.env.VITE_UNMAP_KEY;
const wells = { type: "FeatureCollection", features: [] };
const year = ref<number | [number, number]>(1990);
</script>
<template>
<UnmapMap :api-key="apiKey" :center="[-114.07, 51.05]" :zoom="6">
<UnmapGeojsonLayer id="wells" :data="wells" />
<UnmapTimeSlider v-model="year" :layers="['wells-*']" property="year" :min="1950" :max="2025" cumulative />
</UnmapMap>
</template>Draw on the map
draw puts a drawing toolbar on the map: points, lines, polygons and circles, and a select tool
to move, reshape and delete them. The drawing is terra-draw and the
toolbar is your own ToggleGroup, Button and Tooltip. What you draw comes back as a GeoJSON
FeatureCollection.
import { useState } from "react";
import { Map } from "@/components/ui/map";
import { Draw, EMPTY_DRAWING } from "@/components/ui/draw";
export function Sketch() {
const [shapes, setShapes] = useState(EMPTY_DRAWING);
return (
<Map apiKey={key} style="base" center={[-114.07, 51.05]} zoom={13}>
<Draw value={shapes} onValueChange={setShapes} />
</Map>
);
}Try it, then add it as a starting point:
React projects. Installs components/unmap-map-draw.tsx; render <MapDraw apiKey={key} />.
The same in Vue:
<script setup lang="ts">
import { ref } from "vue";
import { UnmapMap } from "@/components/ui/map";
import { UnmapDraw } from "@/components/ui/draw";
const apiKey = import.meta.env.VITE_UNMAP_KEY!;
const shapes = ref({ type: "FeatureCollection" as const, features: [] });
</script>
<template>
<UnmapMap :api-key="apiKey" map-style="base" :center="[-114.07, 51.05]" :zoom="13">
<UnmapDraw v-model="shapes" />
</UnmapMap>
</template>- The types (
DrawValue,DrawFeature) come with the component. In React,import type { DrawValue } from "@/components/ui/draw"; in Vue they are exported from the component file, soimport type { DrawValue } from "@/components/ui/draw/UnmapDraw.vue"types your own state. - No tool is active at first, so the map pans as usual. Pick a tool to draw; pick it again to put
it down.
modeschooses the drawing tools (point,linestring,polygon,circle); select and delete are always there. - Finish a line with a click on its last point, and a polygon with a click on its first point. With the select tool, drag a shape to move it, drag a vertex to reshape it, drag a midpoint to add a vertex, and right-click a vertex to remove it. The delete button removes the selected shape.
- The keys work while the map canvas has focus, so click the map first. While drawing, Escape drops the shape in progress and Enter finishes a line, polygon or circle. With the select tool, Escape deselects, and Delete or Backspace removes the selected shape (Mac laptops have no Delete key).
onValueChange(Vue:v-model) fires when a shape is finished or deleted, and live, on every step, while a shape is dragged or a vertex is edited, not only when the drag ends. Selecting a shape does not fire it. Debounce it before anything expensive, such as a save.- The value is semi-controlled. Passing a new object with different content replaces the shapes.
Leaving
valueas it was, the same object, does not snap the drawing back, so to undo a change pass a new object. The order of keys in the object does not matter, so a value that went through a schema or a database still counts as the echo of what you were told.defaultValuestarts from shapes without controlling them. - Every feature keeps
properties.mode, the tool that drew it, which the drawing needs to edit it again; a circle is a polygon withradiusKilometers. Your own properties and ids are kept. - A value you pass in may leave out
mode: it is taken from the geometry. Positions are trimmed to longitude and latitude, so the elevation in a 3D position from GPS or KML is dropped on load, and coordinates are rounded to 9 decimals. A geometry the drawing cannot edit, such as aMultiPolygon, is skipped with a console warning, and it is left out of the value you get back: keep such shapes in your own state. - Shapes take your
--primary, outlined in your--background;colorsets another. They sit above the map's labels on purpose, so the handles stay in reach. In Vue, the active tool is marked througharia-pressed, in your accent tokens. - A change of mode, style or theme keeps every finished shape and the tool you had; a shape you
were still drawing is dropped. For two drawings on one map, give each its own
id. - Draw answers map clicks too, so run one of
draw,measureandfeature-inspectorat a time. - While
drawis mounted the browser's right-click menu is off on the map canvas, because the drawing uses right-click to remove a vertex. - Drawing makes no requests: the shapes stay in the page until you send them somewhere.
- The weight is about 35 KB gzipped for terra-draw, tree-shaken to the five modes, and about 3 KB
for its MapLibre adapter. It loads with the component, so put
drawon the pages that need it. lang="fr"labels the toolbar in French, andpositiontakes any corner.
Compare two maps
compare-swipe puts two maps in one frame with a divider between them: before and after, light
and dark, or one style against another. Both maps share one camera, so panning, zooming, rotating
or tilting either one moves the other with it.
import { CompareSwipe } from "@/components/ui/compare-swipe";
<CompareSwipe
apiKey={key}
center={[-123.12, 49.28]}
zoom={12}
before={{ style: "base", mode: "light" }}
after={{ style: "base", mode: "dark" }}
className="h-96"
/>Try it, then add it as a starting point:
React projects. Installs components/unmap-map-compare.tsx; render <MapCompare apiKey={key} />.
The same in Vue:
<script setup lang="ts">
import { ref } from "vue";
import { UnmapCompareSwipe } from "@/components/ui/compare-swipe";
const apiKey = import.meta.env.VITE_UNMAP_KEY!;
const position = ref(50);
</script>
<template>
<UnmapCompareSwipe
v-model:position="position"
:api-key="apiKey"
:center="[-123.12, 49.28]"
:zoom="12"
:before="{ mapStyle: 'base', mode: 'light' }"
:after="{ mapStyle: 'base', mode: 'dark' }"
class="h-96"
/>
</template>A layer that belongs to one side goes in that side's children, with that side's mapId:
<CompareSwipe
apiKey={key}
center={[-123.12, 49.28]}
zoom={12}
after={{ children: <GeoJSONLayer mapId="after" id="parks" data={parks} /> }}
/>beforeshows left of the divider andafterright of it;orientation="horizontal"putsbeforeabove andafterbelow.beforeandaftertake the map's own props for that side: style, mode, theme, overlays, language. The key, the camera (center,zoom,bearing,pitch) and the camera limits are set once, on the comparison. Set the limits withmaxBounds,minZoomandmaxZoomthere, so both maps share them.- A layer on one side only goes in that side's
children(Vue: the#beforeor#afterslot), and it needs that side's map id:mapId="before"ormapId="after"(Vue:map-id). Without it the layer looks for the default map, which is not one of the two.beforeIdandafterIdrename the two maps, and the layer takes the new name. - The divider is a window splitter: drag it, or focus it and use the arrow keys (5% a press,
stepto change it), Home and End.positionandonPositionChange(Vue:v-model:position) control it, anddefaultPositionsets where it starts. - To draw on one side, mount
drawin that side'schildrenwith that side'smapId. On the after side, put it in a right-hand corner (position="bottom-right"; the zoom buttons hold the top right), so the divider does not sweep over the toolbar as it moves. onViewportChange(Vue:@viewport-change) reports the shared camera once per move.- Zoom and compass buttons show on the map that owns the top-right corner: the after map, or the
before map when horizontal. Give the other side its own
controlsto show them there too. - The frame is 320px tall at least. Set its height with
className(h-96), or withcontainerStylein React andstylein Vue, which can also setminHeight. - Centre, zoom, bearing and pitch are synced; roll is not.
- Pinching with one finger on each side of the divider is not supported, because each map sees one finger and they pull against each other. Pinch on one side. A new gesture on one side stops the other side's coasting, so the two never fight.
- Each map loads its own style and tiles, and every tile is a request, so a comparison makes about twice the requests of a single map, and is metered per request like any other.
lang="fr"names the divider in French for screen readers.
Elevation profile
elevation-profile charts the ground elevation along a line on your shadcn Chart: a route from
@unmap/routing or the routing panel, a line someone drew, or your own coordinates. It samples
the line every interval metres along the great circle, asks the unmap elevation API for the
heights, and shows the distance, gain, loss, lowest and highest point. Hover the chart and a
marker follows along the line on the map; hover the line and the chart follows.
The example below routes a bike ride near Canmore and floats the profile in your Card over the
bottom of the map once there is a route. On a phone both cards span the width; from sm up the
route form sits top left and the profile bottom right, clear of the map's attribution.
"use client";
import { useState } from "react";
import type { LngLat } from "@unmap/routing";
import { Card, CardContent } from "@/components/ui/card";
import { Map } from "@/components/ui/map";
import { RoutingPanel } from "@/components/ui/routing-panel";
import { ElevationProfile } from "@/components/ui/elevation-profile";
export function RideProfile() {
const apiKey = process.env.NEXT_PUBLIC_UNMAP_KEY!;
const [route, setRoute] = useState<LngLat[] | null>(null);
return (
<div className="h-[640px]">
<Map apiKey={apiKey} style="outdoor" center={[-115.57, 51.18]} zoom={10}>
<Card className="absolute inset-x-4 top-4 z-10 sm:inset-x-auto sm:top-6 sm:left-6 sm:w-80">
<CardContent>
<RoutingPanel apiKey={apiKey} mode="bicycle" onRoute={(next) => setRoute(next.coordinates)} />
</CardContent>
</Card>
{route ? (
<Card className="absolute inset-x-4 bottom-10 z-10 sm:inset-x-auto sm:right-6 sm:w-[28rem] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-4 motion-safe:duration-200">
<CardContent>
<ElevationProfile apiKey={apiKey} line={route} />
</CardContent>
</Card>
) : null}
</Map>
</div>
);
}Try it, then add it as a starting point:
React projects. Installs components/unmap-map-elevation.tsx; render <MapElevation apiKey={key} />.
linetakes[lng, lat]pairs, a GeoJSON LineString, a Feature holding one, or a FeatureCollection such as thevalueof the drawing tool, whose newest line is profiled (points and polygons beside it are skipped). Anything else, or fewer than two points, shows a prompt and sends nothing.- Each request is one call and carries up to 500 points. The default spacing puts at most 500
samples on the line and never goes finer than 30 m, the component's minimum spacing rather than the DEM's native resolution, so a
profile is one call. A smaller
intervalcosts one call per 500 samples, and a line is never sampled more than 5,000 times, so at most 10 calls. Every request is billed, a cancelled one included. The client's automatic retries are off, so a failed request is never sent again behind your back: the Retry button is the only way to resend it, and each press is one call. - A line that keeps changing, such as a vertex being dragged, is sent only once it has held still for 300 ms, so a drag does not fire a request per move. A new line cancels the request still in flight, and the same line passed again, even as a new array, sends nothing.
- To show a line again without paying for it again, pass
cache, such as anew Map()you keep (Vue::cache). A line whose samples are in it shows its profile at once, with no request and no wait, and each profile fetched is added to it. Clear it when the key changes. - Heights are metres in the active DEM's datum. With global release 6, source datums and terrain/surface models vary. Where a sample falls outside the covered area, the chart leaves a gap instead of drawing zero, and gain and loss count only the samples that have a height. A line with no coverage at all shows a "no elevation data" status.
- Gain and loss are the raw sums of the rises and falls between consecutive samples, so they can read high on rough terrain. A failure shows an alert instead of the chart: a key the API refuses, a rate limit, a line too large to send, an elevation service that did not answer, or a request the API rejected. A rate limit or an unanswered request offers a Retry button; the others do not, because sending the same request again would not help.
- Gain is the headline number, with the distance, loss, lowest and highest point beside it. While a new line loads, the previous profile stays on screen, dimmed, and the first load holds the space the result will take, so the panel does not jump. A source line under the chart links to the active DEM suppliers’ credits and licences; keep it visible. Screen readers skip the chart, read the statistics, and hear the distance and gain when a profile arrives.
lang="fr"switches the labels and number formats to French.onProfilereceives the samples and the statistics once they load (nullwhen there is none), for example to colour aroute-lineby elevation with itsgradient; a sample outside the covered area has anullelevation, so skip it or bridge it.onHoverreceives the sample under the pointer.- It adds only an invisible 16 px line to hover, and the marker, on top of the map, so draw the
line itself with
route-line, the routing panel or your drawing tool. Both come back after a style swap. The marker takes your--primary(or the theme's route colour); the chart takes--chart-1. The chart is your shadcnchart, which is Recharts in React: installing the item bringsrecharts3, so a project whosechart.tsxis still on Recharts 2 should update its chart first. - The routing panel hands each route it draws to
onRoute(Vue:@route), which is how the example above feeds the profile.
In Vue the chart is shadcn-vue's chart, which draws with Unovis, so adding the item brings
Unovis's packages with it. @profile and @hover are the events for onProfile and onHover.
<script setup lang="ts">
import { ref } from "vue";
import type { LngLat } from "@unmap/routing";
import { Card, CardContent } from "@/components/ui/card";
import { UnmapMap } from "@/components/ui/map";
import { UnmapRoutingPanel } from "@/components/ui/routing-panel";
import { UnmapElevationProfile } from "@/components/ui/elevation-profile";
const apiKey = import.meta.env.VITE_UNMAP_KEY;
const route = ref<LngLat[] | null>(null);
</script>
<template>
<div class="h-[640px]">
<UnmapMap :api-key="apiKey" map-style="outdoor" :center="[-115.57, 51.18]" :zoom="10">
<Card class="absolute inset-x-4 top-4 z-10 sm:inset-x-auto sm:top-6 sm:left-6 sm:w-80">
<CardContent>
<UnmapRoutingPanel :api-key="apiKey" mode="bicycle" @route="route = $event.coordinates" />
</CardContent>
</Card>
<Card
v-if="route"
class="absolute inset-x-4 bottom-10 z-10 sm:inset-x-auto sm:right-6 sm:w-[28rem] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-4 motion-safe:duration-200"
>
<CardContent>
<UnmapElevationProfile :api-key="apiKey" :line="route" />
</CardContent>
</Card>
</UnmapMap>
</div>
</template>