Skip to content

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/measure

The 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
ItemWhat it is
measureClick 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-inspectorThe 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-sliderYour Slider driving a time window on map layers, with play, pause, step and loop. Works next to a layer-legend on the same layer.
drawDraw 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-swipeTwo maps in one frame with a draggable divider and one shared camera: before and after, or one style against another.
elevation-profileThe 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:

npx @unmap/cli add map-measure-01

React projects. Installs components/unmap-map-measure.tsx; render <MapMeasure apiKey={key} />.

Open the example page
  • 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 km and 4 430 km, with a no-break space between thousands and before the unit, and names the buttons in French. format replaces 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} or active={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:

npx @unmap/cli add map-inspect-01

React projects. Installs components/unmap-map-inspect.tsx; render <MapInspect apiKey={key} />.

Open the example page
  • By default every layer is queried: the basemap's roads, places and water, and your own data. layers limits 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 to limit rows (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 by name:fr when they carry one. The outline survives a style swap; unmounting removes it. onSelect (Vue: @select) receives the selected feature, or null.
  • 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 measure tool 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:

npx @unmap/cli add map-timeline-01

React projects. Installs components/unmap-map-timeline.tsx; render <MapTimeline apiKey={key} />.

Open the example page
  • A single value shows one step: 2001 with step={1} shows the features whose year is at least 2001 and under 2002. cumulative shows everything before the end of the step instead. A pair, such as defaultValue={[1990, 2000]}, shows the features between the two, both ends included, and keeps its width when it plays.
  • Play moves one step every interval milliseconds (1,000 by default) and stops at the end; loop starts over instead. Pressing play at the end starts from the beginning.
  • The state is uncontrolled with defaultValue, or controlled with value and onValueChange (Vue: v-model). format writes 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-legend unchecks 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, so v-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:

npx @unmap/cli add map-draw-01

React projects. Installs components/unmap-map-draw.tsx; render <MapDraw apiKey={key} />.

Open the example page

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, so import 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. modes chooses 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 value as 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. defaultValue starts 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 with radiusKilometers. 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 a MultiPolygon, 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; color sets another. They sit above the map's labels on purpose, so the handles stay in reach. In Vue, the active tool is marked through aria-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, measure and feature-inspector at a time.
  • While draw is 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 draw on the pages that need it.
  • lang="fr" labels the toolbar in French, and position takes 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:

npx @unmap/cli add map-compare-01

React projects. Installs components/unmap-map-compare.tsx; render <MapCompare apiKey={key} />.

Open the example page

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} /> }}
/>
  • before shows left of the divider and after right of it; orientation="horizontal" puts before above and after below.
  • before and after take 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 with maxBounds, minZoom and maxZoom there, so both maps share them.
  • A layer on one side only goes in that side's children (Vue: the #before or #after slot), and it needs that side's map id: mapId="before" or mapId="after" (Vue: map-id). Without it the layer looks for the default map, which is not one of the two. beforeId and afterId rename 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, step to change it), Home and End. position and onPositionChange (Vue: v-model:position) control it, and defaultPosition sets where it starts.
  • To draw on one side, mount draw in that side's children with that side's mapId. 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 controls to show them there too.
  • The frame is 320px tall at least. Set its height with className (h-96), or with containerStyle in React and style in Vue, which can also set minHeight.
  • 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:

npx @unmap/cli add map-elevation-01

React projects. Installs components/unmap-map-elevation.tsx; render <MapElevation apiKey={key} />.

Open the example page
  • line takes [lng, lat] pairs, a GeoJSON LineString, a Feature holding one, or a FeatureCollection such as the value of 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 interval costs 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 a new 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. onProfile receives the samples and the statistics once they load (null when there is none), for example to colour a route-line by elevation with its gradient; a sample outside the covered area has a null elevation, so skip it or bridge it. onHover receives 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 shadcn chart, which is Recharts in React: installing the item brings recharts 3, so a project whose chart.tsx is 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>