All blocks

templates

Location Resolver

Search + map + boundary overlay with inline hierarchy breadcrumb — powered by points.resolve.

Open playground →

Live preview

Search for a place or click the map — the boundary renders and the hierarchy trail appears above the map. Open full-screen playground →

Search or click the map to resolve a location — resolved taxonomy appears here.

Related

Install

$bunx shadcn@latest add https://mapbase.dev/r/location-resolver.json

Copies source into your app and installs npm + registry dependencies.

npm dependencies

mapbasemaplibre-gllucide-reactmotion@tanstack/react-query

Registry dependencies

https://www.mapcn.dev/r/map.jsonhttps://mapbase.dev/r/mapbase-provider.jsonhttps://mapbase.dev/r/autocomplete.json

Source

components/mapbase/location-resolver.tsx
"use client"

import * as React from "react"
import {
  orderedHierarchy,
  type CountryCode,
  type Layer,
  type LocationSuggestion,
  type PlaceSourceName,
  type PlaceSuggestion,
  type Taxonomy,
  MapbaseError,
} from "mapbase"
import {
  useBoundary,
  useOptionalMapbase,
  usePointResolve,
} from "mapbase/react"
import { ChevronRight } from "lucide-react"

import {
  Map,
  MapControls,
  MapMarker,
  MarkerContent,
  type MapRef,
  type MapViewport,
} from "@/components/ui/map"
import { cn } from "@/lib/utils"
import { Autocomplete } from "@/components/mapbase/autocomplete"
import { MapbaseProvider } from "@/components/mapbase/mapbase-provider"
import {
  LAYER_COLORS,
  pickRenderablePolylines,
  useMapBoundaryLayer,
  useMapPolylinesLayer,
} from "@/lib/mapbase/map-layers"

export type LocationResolverProps = {
  apiKey?: string
  baseUrl?: string
  source?: PlaceSourceName
  country?: CountryCode
  layers?: Layer[]
  placeholder?: string
  className?: string
  height?: string | number
  onSelect?: (suggestion: PlaceSuggestion) => void
  onResolve?: (taxonomy: Taxonomy) => void
}

function zoomForTaxonomy(taxonomy: Taxonomy | null): number {
  if (!taxonomy?.anchor) return 6
  const level = taxonomy.anchor.level
  if (level == null) return 10
  if (level <= 1) return 5
  if (level === 2) return 7
  if (level === 3) return 10
  if (level === 4) return 12
  return 14
}

/** Prefer autocomplete polylines, then resolved anchor / zones / hierarchy. */
function collectOverlayPolylines(
  selectionRow: LocationSuggestion | null,
  taxonomy: Taxonomy | null,
): string[] | null {
  const fromTaxonomy = taxonomy
    ? pickRenderablePolylines([
        taxonomy.anchor?.polylines,
        ...taxonomy.contains.map((node) => node.polylines),
        ...orderedHierarchy(taxonomy.hierarchy)
          .slice()
          .reverse()
          .map((node) => node.polylines),
      ])
    : null

  return pickRenderablePolylines([selectionRow?.polylines, fromTaxonomy])
}

function overlayLayer(
  selectionRow: LocationSuggestion | null,
  taxonomy: Taxonomy | null,
): Layer {
  if (selectionRow?.layer) return selectionRow.layer
  const zone = taxonomy?.contains.find((node) => node.polylines?.length)
  if (zone?.layer) return zone.layer
  return taxonomy?.anchor?.layer ?? "locations"
}

export function LocationResolver(props: LocationResolverProps) {
  const { apiKey, baseUrl, country, source } = props
  if (apiKey) {
    return (
      <MapbaseProvider
        apiKey={apiKey}
        baseUrl={baseUrl}
        country={country}
        source={source}
      >
        <LocationResolverInner {...props} />
      </MapbaseProvider>
    )
  }
  return <LocationResolverInner {...props} />
}

function LocationResolverInner({
  apiKey,
  baseUrl,
  source,
  country,
  layers,
  placeholder = "Search for a place…",
  className,
  height = "480px",
  onSelect,
  onResolve,
}: LocationResolverProps) {
  const mapRef = React.useRef<MapRef>(null)
  const ctx = useOptionalMapbase()
  const resolvedSource = source ?? ctx?.placeSource.name ?? "mapbase"

  const [selection, setSelection] = React.useState<PlaceSuggestion | null>(null)

  const { taxonomy, loading, error, resolveSuggestion, resolveCoordinate } =
    usePointResolve({
      apiKey,
      baseUrl,
      country,
      include: ["centroid", "bbox", "polylines", "postcodes"],
    })

  const selectionRow = (selection?.ref as LocationSuggestion | undefined) ?? null
  const overlayPolylines = collectOverlayPolylines(selectionRow, taxonomy)
  const showPolylines = Boolean(overlayPolylines?.length)
  const fitBbox = selectionRow?.bbox ?? taxonomy?.anchor?.bbox ?? null
  const overlayLayerKey = overlayLayer(selectionRow, taxonomy)

  const boundaryPlaceId =
    resolvedSource === "mapbase" && !showPolylines
      ? (selectionRow?.id ?? taxonomy?.anchor?.id ?? null)
      : null

  const { boundary, loading: boundaryLoading } = useBoundary({
    apiKey,
    baseUrl,
    placeId: boundaryPlaceId,
    enabled: resolvedSource === "mapbase" && Boolean(boundaryPlaceId),
  })

  useMapPolylinesLayer(
    mapRef,
    overlayPolylines,
    fitBbox,
    showPolylines,
    "mapbase-polylines",
    {
      fillColor: LAYER_COLORS[overlayLayerKey].fill,
      fillOpacity: 0.12,
      lineColor: LAYER_COLORS[overlayLayerKey].line,
      lineWidth: 2,
    },
  )
  useMapBoundaryLayer(
    mapRef,
    boundary && !showPolylines ? boundary : null,
    Boolean(boundary && !showPolylines),
    0.15,
    overlayLayerKey,
  )

  const markerPoint = taxonomy?.point ?? selectionRow?.centroid ?? null
  const [lng, lat] = markerPoint ?? [-9.1393, 38.7223]
  const [viewport, setViewport] = React.useState<MapViewport>({
    center: [lng, lat],
    zoom: zoomForTaxonomy(taxonomy),
    bearing: 0,
    pitch: 0,
  })

  React.useEffect(() => {
    const point = taxonomy?.point ?? selectionRow?.centroid
    if (!point) return
    const [nextLng, nextLat] = point
    setViewport((prev) => ({
      ...prev,
      center: [nextLng, nextLat],
      zoom: Math.max(prev.zoom, zoomForTaxonomy(taxonomy)),
    }))
  }, [taxonomy, selectionRow?.centroid])

  const handleSelect = async (suggestion: PlaceSuggestion) => {
    setSelection(suggestion)
    onSelect?.(suggestion)
    const resolved = await resolveSuggestion(suggestion)
    if (resolved) onResolve?.(resolved)
  }

  const handleMapClick = React.useCallback(
    async (clickLng: number, clickLat: number) => {
      if (loading) return
      const resolved = await resolveCoordinate(clickLng, clickLat)
      if (resolved) {
        setSelection(null)
        onResolve?.(resolved)
      }
    },
    [loading, onResolve, resolveCoordinate],
  )

  React.useEffect(() => {
    let cleanup: (() => void) | undefined
    let cancelled = false

    const attach = () => {
      const map = mapRef.current
      if (!map || cancelled) return false
      const onClick = (e: { lngLat: { lng: number; lat: number } }) => {
        void handleMapClick(e.lngLat.lng, e.lngLat.lat)
      }
      map.on("click", onClick)
      cleanup = () => {
        map.off("click", onClick)
      }
      return true
    }

    if (!attach()) {
      const id = window.setInterval(() => {
        if (attach()) window.clearInterval(id)
      }, 150)
      return () => {
        cancelled = true
        window.clearInterval(id)
        cleanup?.()
      }
    }

    return () => {
      cancelled = true
      cleanup?.()
    }
  }, [handleMapClick])

  const styleHeight = typeof height === "number" ? `${height}px` : height
  const hierarchyNodes = taxonomy ? orderedHierarchy(taxonomy.hierarchy) : []
  const anchorId = taxonomy?.anchor?.id

  return (
    <div
      className={cn(
        "relative isolate w-full overflow-hidden rounded-md border",
        className,
      )}
      style={{ height: styleHeight }}
    >
      <Map
        ref={mapRef}
        viewport={viewport}
        onViewportChange={setViewport}
        className="absolute inset-0 h-full w-full cursor-crosshair"
      >
        <MapControls
          position="bottom-right"
          showZoom
          className="!bottom-24 !right-4"
        />
        {markerPoint ? (
          <MapMarker longitude={lng} latitude={lat}>
            <MarkerContent>
              <span className="relative flex size-10 items-center justify-center">
                <span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/25" />
                <span className="relative size-4 rounded-full border-[3px] border-background bg-primary shadow-[0_0_24px_var(--primary)]" />
              </span>
            </MarkerContent>
          </MapMarker>
        ) : null}
      </Map>

      {hierarchyNodes.length > 0 ? (
        <nav
          aria-label="Location hierarchy"
          className="pointer-events-none absolute left-3 top-3 z-20 flex max-w-[calc(100%-1.5rem)] flex-wrap items-center gap-1"
        >
          {hierarchyNodes.map((node, i) => (
            <React.Fragment key={node.id}>
              {i > 0 ? (
                <ChevronRight className="size-3 shrink-0 text-muted-foreground" aria-hidden />
              ) : null}
              <span
                className={cn(
                  "rounded-full border bg-background/90 px-2.5 py-1 text-[11px] backdrop-blur-sm",
                  node.id === anchorId
                    ? "border-primary/40 font-medium text-foreground"
                    : "border-border text-muted-foreground",
                )}
              >
                {node.name}
              </span>
            </React.Fragment>
          ))}
        </nav>
      ) : null}

      {error ? (
        <div className="absolute right-3 top-3 z-20 max-w-xs rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm">
          <p className="font-medium">{humanizeErrorCode(error.code)}</p>
          <p className="text-muted-foreground">{error.message}</p>
        </div>
      ) : null}

      {loading || boundaryLoading ? (
        <div className="pointer-events-none absolute top-1/2 left-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full border bg-background/80 px-4 py-2 text-xs text-muted-foreground backdrop-blur-md">
          {loading ? "Resolving…" : "Loading boundary…"}
        </div>
      ) : null}

      <div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 px-3 pb-3">
        <div className="pointer-events-auto mx-auto w-full max-w-xl">
          <Autocomplete
            apiKey={apiKey}
            baseUrl={baseUrl}
            source={source}
            country={country}
            layers={layers}
            include={["centroid", "bbox", "polylines"]}
            variant="inline"
            resultsSide="top"
            value={selection}
            onChange={setSelection}
            onSelect={handleSelect}
            placeholder={placeholder}
          />
        </div>
      </div>
    </div>
  )
}

function humanizeErrorCode(code: MapbaseError["code"]): string {
  switch (code) {
    case "unauthorized":
      return "Invalid API key"
    case "rate_limited":
      return "Rate limited"
    case "not_found":
      return "No location at this point"
    case "network_error":
      return "Network error"
    default:
      return "Something went wrong"
  }
}
lib/mapbase/map-layers.ts
"use client"

import * as React from "react"
import {
  polylinesToGeoJson,
  type BoundaryGeometry,
  type Layer,
  type PolygonFeaturesGeom,
} from "mapbase"

import type { MapRef } from "@/components/ui/map"

/** Per-layer map colors for boundary and polyline overlays. */
export const LAYER_COLORS: Record<Layer, { fill: string; line: string }> = {
  locations: { fill: "#eca8d6", line: "#ffffff" },
  postcodes: { fill: "#7dd3fc", line: "#bae6fd" },
  lau: { fill: "#a3e635", line: "#ecfccb" },
  zones: { fill: "#fbbf24", line: "#fef3c7" },
}

export type GeoJsonLayerPaint = {
  fillColor?: string
  fillOpacity?: number
  lineColor?: string
  lineWidth?: number
  lineDasharray?: number[]
}

const DEFAULT_PAINT: Required<Omit<GeoJsonLayerPaint, "lineDasharray">> & {
  lineDasharray?: number[]
} = {
  fillColor: "#eca8d6",
  fillOpacity: 0.15,
  lineColor: "#ffffff",
  lineWidth: 2,
}

type GeoJsonLayerOptions = {
  mapRef: React.RefObject<MapRef | null>
  sourceId: string
  fillLayerId: string
  lineLayerId: string
  geometry: BoundaryGeometry | PolygonFeaturesGeom | null | undefined
  paint?: GeoJsonLayerPaint
  fitBounds?: [number, number, number, number] | null
  enabled?: boolean
}

function applyPaintTransition(
  map: MapRef,
  layerId: string,
  prop: string,
  value: unknown
) {
  try {
    map.setPaintProperty?.(layerId, prop, value)
  } catch {
    /* layer may not exist yet */
  }
}

export function useMapGeoJsonLayer({
  mapRef,
  sourceId,
  fillLayerId,
  lineLayerId,
  geometry,
  paint,
  fitBounds,
  enabled = true,
}: GeoJsonLayerOptions) {
  const paintRef = React.useRef({ ...DEFAULT_PAINT, ...paint })
  paintRef.current = { ...DEFAULT_PAINT, ...paint }

  React.useEffect(() => {
    const map = mapRef.current
    if (!map || !enabled || !geometry) return

    const apply = () => {
      const p = paintRef.current
      const data = {
        type: "Feature" as const,
        properties: {},
        geometry,
      }

      const existing = map.getSource(sourceId)
      if (existing) {
        ;(
          existing as unknown as { setData: (geojson: unknown) => void }
        ).setData(data)
      } else {
        map.addSource(sourceId, { type: "geojson", data })
        map.addLayer({
          id: fillLayerId,
          type: "fill",
          source: sourceId,
          paint: {
            "fill-color": p.fillColor,
            "fill-opacity": 0,
          },
        })
        map.addLayer({
          id: lineLayerId,
          type: "line",
          source: sourceId,
          paint: {
            "line-color": p.lineColor,
            "line-width": p.lineWidth,
            "line-opacity": 0,
            ...(p.lineDasharray ? { "line-dasharray": p.lineDasharray } : {}),
          },
        })
        window.requestAnimationFrame(() => {
          applyPaintTransition(map, fillLayerId, "fill-opacity", p.fillOpacity)
          applyPaintTransition(map, lineLayerId, "line-opacity", 1)
        })
      }

      applyPaintTransition(map, fillLayerId, "fill-opacity", p.fillOpacity)
      applyPaintTransition(map, fillLayerId, "fill-color", p.fillColor)
      applyPaintTransition(map, lineLayerId, "line-color", p.lineColor)

      if (fitBounds) {
        map.fitBounds(
          [
            [fitBounds[0], fitBounds[1]],
            [fitBounds[2], fitBounds[3]],
          ],
          { padding: 48, duration: 700, maxZoom: 14 }
        )
      }
    }

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

    return () => {
      const m = mapRef.current
      if (!m) return
      if (m.getLayer(lineLayerId)) m.removeLayer(lineLayerId)
      if (m.getLayer(fillLayerId)) m.removeLayer(fillLayerId)
      if (m.getSource(sourceId)) m.removeSource(sourceId)
    }
  }, [
    mapRef,
    sourceId,
    fillLayerId,
    lineLayerId,
    geometry,
    fitBounds,
    enabled,
    paint?.fillColor,
    paint?.fillOpacity,
    paint?.lineColor,
    paint?.lineWidth,
  ])
}

/** True when encoded polylines decode to drawable polygon geometry. */
export function hasRenderablePolylines(
  polylines: string[] | null | undefined
): boolean {
  if (!polylines?.length) return false
  return polylinesToGeoJson(polylines) != null
}

/** First non-empty, renderable polyline set from the given sources. */
export function pickRenderablePolylines(
  sources: (string[] | null | undefined)[]
): string[] | null {
  for (const polylines of sources) {
    if (hasRenderablePolylines(polylines)) return polylines!
  }
  return null
}

export function useMapPolylinesLayer(
  mapRef: React.RefObject<MapRef | null>,
  polylines: string[] | null | undefined,
  fitBounds: [number, number, number, number] | null | undefined,
  enabled = true,
  idPrefix = "mapbase-polylines",
  paint?: GeoJsonLayerPaint
) {
  const geometry = React.useMemo(
    () =>
      hasRenderablePolylines(polylines) ? polylinesToGeoJson(polylines!) : null,
    [polylines]
  )

  useMapGeoJsonLayer({
    mapRef,
    sourceId: idPrefix,
    fillLayerId: `${idPrefix}-fill`,
    lineLayerId: `${idPrefix}-line`,
    geometry,
    fitBounds,
    enabled: enabled && Boolean(geometry),
    paint,
  })
}

export function useMapBoundaryLayer(
  mapRef: React.RefObject<MapRef | null>,
  boundary: {
    geometry: BoundaryGeometry
    bbox: [number, number, number, number]
  } | null,
  enabled = true,
  fillOpacity = 0.15,
  layer: Layer = "locations"
) {
  const colors = LAYER_COLORS[layer]
  useMapGeoJsonLayer({
    mapRef,
    sourceId: "mapbase-boundary",
    fillLayerId: "mapbase-boundary-fill",
    lineLayerId: "mapbase-boundary-line",
    geometry: boundary?.geometry ?? null,
    fitBounds: boundary?.bbox ?? null,
    enabled: enabled && Boolean(boundary),
    paint: {
      fillColor: colors.fill,
      fillOpacity,
      lineColor: colors.line,
      lineWidth: 2,
    },
  })
}