All blocks

Boundary Map

Render the polygon for any Mapbase place id on a MapLibre map, framed to its bbox. Composes shadcn Card with the mapcn Map primitive.

Install

$bunx shadcn@latest add https://mapbase.dev/r/boundary-map.json

The shadcn CLI copies the file(s) below into your tree, installs npm dependencies, and recursively pulls registry dependencies (including from cross-registry URLs).

npm dependencies

mapbasemaplibre-gl

Registry dependencies

cardhttps://www.mapcn.dev/r/map.json

Source

components/mapbase/boundary-map.tsx
"use client"

import * as React from "react"
import { Mapbase, type Boundary } from "mapbase"

import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  Map,
  MapControls,
  type MapRef,
  type MapViewport,
} from "@/components/ui/map"
import { cn } from "@/lib/utils"

export type BoundaryMapProps = {
  /**
   * Public Mapbase API key (`mb_live_*`). Mint one at
   * https://dashboard.mapbase.dev/api-keys.
   */
  apiKey: string
  /** Optional: pin the engine URL. Defaults to https://api.mapbase.dev. */
  baseUrl?: string
  /**
   * Place id whose boundary should be loaded. Pass `null` (or omit) to
   * leave the map empty until you've resolved one — typically via the
   * autocomplete-location or geocode-input components.
   */
  placeId?: string | null
  /** Optional pre-fetched boundary; skips the network round-trip. */
  boundary?: Boundary | null
  /** Title rendered in the card header. */
  title?: string
  className?: string
}

const SOURCE_ID = "mapbase-boundary"
const FILL_LAYER_ID = "mapbase-boundary-fill"
const LINE_LAYER_ID = "mapbase-boundary-line"

const DEFAULT_VIEWPORT: MapViewport = {
  center: [-4.5, 40.0],
  zoom: 4.5,
  bearing: 0,
  pitch: 0,
}

/**
 * Render the polygon for any Mapbase place id on a MapLibre map.
 *
 * The map is framed to the boundary bbox automatically; the polygon is
 * drawn as a translucent fill plus a stroke. Built on shadcn Card and
 * the mapcn `Map` primitive — no other UI deps.
 */
export function BoundaryMap({
  apiKey,
  baseUrl,
  placeId,
  boundary: controlledBoundary,
  title = "Boundary",
  className,
}: BoundaryMapProps) {
  const client = React.useMemo(
    () => new Mapbase(apiKey, baseUrl ? { baseUrl } : undefined),
    [apiKey, baseUrl],
  )

  const [boundary, setBoundary] = React.useState<Boundary | null>(
    controlledBoundary ?? null,
  )
  const [viewport, setViewport] = React.useState<MapViewport>(DEFAULT_VIEWPORT)
  const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
  const mapRef = React.useRef<MapRef>(null)

  React.useEffect(() => {
    if (controlledBoundary !== undefined) {
      setBoundary(controlledBoundary)
    }
  }, [controlledBoundary])

  // Fetch when only `placeId` is supplied (not `boundary`).
  React.useEffect(() => {
    if (controlledBoundary !== undefined) return
    if (!placeId) {
      setBoundary(null)
      return
    }

    const controller = new AbortController()
    void (async () => {
      const result = await client.boundaries.byId(placeId, {
        signal: controller.signal,
      })
      if (controller.signal.aborted) return
      if (result.error) {
        setBoundary(null)
        setErrorMessage(humanizeError(result.error.code))
        return
      }
      setBoundary(result.data)
      setErrorMessage(null)
    })()

    return () => controller.abort()
  }, [client, placeId, controlledBoundary])

  // Frame the map and draw the polygon every time `boundary` changes.
  // Sources / layers are added on the underlying MapLibre map directly;
  // we tear them down on cleanup so re-renders don't stack duplicates.
  React.useEffect(() => {
    const map = mapRef.current
    if (!map || !boundary) return

    const apply = () => {
      const existing = map.getSource(SOURCE_ID)
      if (existing) {
        // mapcn's MapRef exposes the standard MapLibre Map surface.
        ;(existing as { setData: (geojson: unknown) => void }).setData({
          type: "Feature",
          properties: {},
          geometry: boundary.geometry,
        })
      } else {
        map.addSource(SOURCE_ID, {
          type: "geojson",
          data: {
            type: "Feature",
            properties: {},
            geometry: boundary.geometry,
          },
        })
        map.addLayer({
          id: FILL_LAYER_ID,
          type: "fill",
          source: SOURCE_ID,
          paint: {
            "fill-color": "#3b82f6",
            "fill-opacity": 0.18,
          },
        })
        map.addLayer({
          id: LINE_LAYER_ID,
          type: "line",
          source: SOURCE_ID,
          paint: {
            "line-color": "#2563eb",
            "line-width": 2,
          },
        })
      }

      map.fitBounds(
        [
          [boundary.bbox[0], boundary.bbox[1]],
          [boundary.bbox[2], boundary.bbox[3]],
        ],
        { padding: 32, duration: 500 },
      )
    }

    if (map.isStyleLoaded()) apply()
    else map.once("load", apply)

    return () => {
      if (!mapRef.current) return
      const m = mapRef.current
      if (m.getLayer(LINE_LAYER_ID)) m.removeLayer(LINE_LAYER_ID)
      if (m.getLayer(FILL_LAYER_ID)) m.removeLayer(FILL_LAYER_ID)
      if (m.getSource(SOURCE_ID)) m.removeSource(SOURCE_ID)
    }
  }, [boundary])

  return (
    <Card className={cn("overflow-hidden", className)}>
      <CardHeader>
        <CardTitle>{title}</CardTitle>
        <CardDescription>
          {boundary
            ? `bbox: [${boundary.bbox.map((n) => n.toFixed(3)).join(", ")}]`
            : "Pass a placeId to load a boundary."}
        </CardDescription>
      </CardHeader>
      <CardContent>
        <div className="relative h-[360px] w-full overflow-hidden rounded-md border">
          <Map
            ref={mapRef}
            viewport={viewport}
            onViewportChange={setViewport}
            className="h-full w-full"
          >
            <MapControls position="bottom-right" showZoom />
          </Map>
          {errorMessage ? (
            <div className="absolute right-2 top-2 rounded-md bg-background/80 px-2 py-1 text-xs text-destructive backdrop-blur">
              {errorMessage}
            </div>
          ) : null}
        </div>
      </CardContent>
    </Card>
  )
}

function humanizeError(code: string): string {
  switch (code) {
    case "not_found":
      return "No boundary stored for that place."
    case "unauthorized":
      return "Invalid API key."
    case "rate_limited":
      return "Rate limited — try again shortly."
    case "network_error":
      return "Network error."
    default:
      return "Failed to load boundary."
  }
}