All blocks

Geocode Resolve Map

Drag a map marker (or edit coordinates) to resolve WGS84 points via /v1/geocode/resolve. Shows ad_1..ad_4, hierarchy, and zones_detail.

Live preview

Resolve on map
Drag the marker or edit coordinates to resolve administrative context via /v1/geocode/resolve.

Install

$bunx shadcn@latest add https://mapbase.dev/r/geocode-resolve-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

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

Source

components/mapbase/geocode-resolve-map.tsx
"use client"

import * as React from "react"
import {
  Mapbase,
  MapbaseError,
  type CountryCode,
  type GeocodeResolveResponse,
} from "mapbase"

import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
  Map,
  MapControls,
  MapMarker,
  MarkerContent,
  type MapViewport,
} from "@/components/ui/map"
import { cn } from "@/lib/utils"

export type GeocodeResolveMapProps = {
  apiKey: string
  baseUrl?: string
  /** Initial marker position. Defaults to central Lisbon. */
  initialLng?: number
  initialLat?: number
  /** Optional country filter forwarded to `/v1/geocode/resolve`. */
  country?: CountryCode
  /** Debounce window after drag or manual coord edits. Defaults to 350 ms. */
  debounceMs?: number
  /** Show manual longitude / latitude inputs. Defaults to true. */
  showCoordInputs?: boolean
  /** Fired when a resolve completes. */
  onResolve?: (result: GeocodeResolveResponse | null) => void
  className?: string
}

const DEFAULT_LNG = -9.1393
const DEFAULT_LAT = 38.7223

/**
 * Interactive resolve playground: drag a marker (or type coordinates) and
 * see the Mapbase administrative hierarchy for that WGS84 point.
 */
export function GeocodeResolveMap({
  apiKey,
  baseUrl,
  initialLng = DEFAULT_LNG,
  initialLat = DEFAULT_LAT,
  country,
  debounceMs = 350,
  showCoordInputs = true,
  onResolve,
  className,
}: GeocodeResolveMapProps) {
  const client = React.useMemo(
    () => new Mapbase(apiKey, baseUrl ? { baseUrl } : undefined),
    [apiKey, baseUrl],
  )

  const [lng, setLng] = React.useState(initialLng)
  const [lat, setLat] = React.useState(initialLat)
  const [lngInput, setLngInput] = React.useState(String(initialLng))
  const [latInput, setLatInput] = React.useState(String(initialLat))
  const [viewport, setViewport] = React.useState<MapViewport>({
    center: [initialLng, initialLat],
    zoom: 12,
    bearing: 0,
    pitch: 0,
  })
  const [result, setResult] = React.useState<GeocodeResolveResponse | null>(
    null,
  )
  const [loading, setLoading] = React.useState(false)
  const [error, setError] = React.useState<MapbaseError | null>(null)

  const countryKey = country ?? ""

  const runResolve = React.useCallback(
    (nextLng: number, nextLat: number, signal: AbortSignal) => {
      void (async () => {
        setLoading(true)
        setError(null)

        const response = await client.geocode.resolve(
          {
            lng: nextLng,
            lat: nextLat,
            ...(country ? { country } : {}),
          },
          { signal },
        )

        if (signal.aborted) return

        if (response.error) {
          setError(response.error)
          setResult(null)
          onResolve?.(null)
        } else {
          setResult(response.data)
          onResolve?.(response.data)
        }
        setLoading(false)
      })()
    },
    [client, country, onResolve],
  )

  React.useEffect(() => {
    if (!Number.isFinite(lng) || !Number.isFinite(lat)) return

    const controller = new AbortController()
    const timer = window.setTimeout(() => {
      runResolve(lng, lat, controller.signal)
    }, debounceMs)

    return () => {
      controller.abort()
      window.clearTimeout(timer)
    }
  }, [lng, lat, countryKey, debounceMs, runResolve])

  const handleDragEnd = React.useCallback(
    ({ lng: nextLng, lat: nextLat }: { lng: number; lat: number }) => {
      setLng(nextLng)
      setLat(nextLat)
      setLngInput(nextLng.toFixed(6))
      setLatInput(nextLat.toFixed(6))
      setViewport((prev) => ({ ...prev, center: [nextLng, nextLat] }))
    },
    [],
  )

  const applyManualCoords = () => {
    const nextLng = Number.parseFloat(lngInput)
    const nextLat = Number.parseFloat(latInput)
    if (!Number.isFinite(nextLng) || !Number.isFinite(nextLat)) return
    setLng(nextLng)
    setLat(nextLat)
    setViewport((prev) => ({ ...prev, center: [nextLng, nextLat] }))
  }

  return (
    <Card className={cn("overflow-hidden", className)}>
      <CardHeader>
        <CardTitle>Resolve on map</CardTitle>
        <CardDescription>
          Drag the marker or edit coordinates to resolve administrative
          context via{" "}
          <code className="font-mono text-xs">/v1/geocode/resolve</code>.
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {showCoordInputs ? (
          <div className="flex flex-wrap items-end gap-3">
            <div className="flex min-w-[8rem] flex-col gap-1.5">
              <Label htmlFor="geocode-resolve-lng" className="text-xs">
                Longitude
              </Label>
              <Input
                id="geocode-resolve-lng"
                inputMode="decimal"
                value={lngInput}
                onChange={(e) => setLngInput(e.target.value)}
                className="h-8 font-mono text-sm"
              />
            </div>
            <div className="flex min-w-[8rem] flex-col gap-1.5">
              <Label htmlFor="geocode-resolve-lat" className="text-xs">
                Latitude
              </Label>
              <Input
                id="geocode-resolve-lat"
                inputMode="decimal"
                value={latInput}
                onChange={(e) => setLatInput(e.target.value)}
                className="h-8 font-mono text-sm"
              />
            </div>
            <Button
              type="button"
              variant="outline"
              size="sm"
              className="h-8"
              onClick={applyManualCoords}
            >
              Apply
            </Button>
          </div>
        ) : null}

        <div className="relative h-[320px] w-full overflow-hidden rounded-md border">
          <Map
            viewport={viewport}
            onViewportChange={setViewport}
            className="h-full w-full"
          >
            <MapControls position="bottom-right" showZoom />
            <MapMarker
              longitude={lng}
              latitude={lat}
              draggable
              onDragEnd={handleDragEnd}
            >
              <MarkerContent>
                <div className="size-3 rounded-full bg-primary ring-2 ring-background shadow-md" />
              </MarkerContent>
            </MapMarker>
          </Map>
          {loading ? (
            <div className="absolute right-2 top-2 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground backdrop-blur">
              Resolving…
            </div>
          ) : null}
        </div>

        {error ? (
          <div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
            <p className="font-medium text-destructive">
              {humanizeErrorCode(error.code)}
            </p>
            <p className="text-muted-foreground">{error.message}</p>
          </div>
        ) : null}

        {result ? <ResolveResultPanel result={result} /> : null}
      </CardContent>
    </Card>
  )
}

function ResolveResultPanel({ result }: { result: GeocodeResolveResponse }) {
  const adminRows = [
    {
      slot: "ad_1",
      name: result.ad_1,
      id: result.ad_1_id,
      uri: result.ad_1_uri,
      kind: result.ad_1_kind,
    },
    {
      slot: "ad_2",
      name: result.ad_2,
      id: result.ad_2_id,
      uri: result.ad_2_uri,
      kind: result.ad_2_kind,
    },
    {
      slot: "ad_3",
      name: result.ad_3,
      id: result.ad_3_id,
      uri: result.ad_3_uri,
      kind: result.ad_3_kind,
    },
    {
      slot: "ad_4",
      name: result.ad_4,
      id: result.ad_4_id,
      uri: result.ad_4_uri,
      kind: result.ad_4_kind,
    },
  ]

  return (
    <div className="flex flex-col gap-3 rounded-md border bg-muted/40 p-3 text-sm">
      <div>
        <div className="font-medium">{result.anchor.name}</div>
        <div className="text-xs text-muted-foreground">
          {result.country}
          {result.coord
            ? ` · ${result.coord[0].toFixed(6)}, ${result.coord[1].toFixed(6)}`
            : null}
        </div>
      </div>

      <div className="grid gap-1 text-xs">
        {adminRows.map((row) =>
          row.name ? (
            <div key={row.slot} className="grid grid-cols-[3rem_1fr] gap-2">
              <span className="font-mono text-muted-foreground">
                {row.slot}
              </span>
              <span>
                {row.name}
                {row.id ? (
                  <span className="text-muted-foreground"> · {row.id}</span>
                ) : null}
                {row.uri ? (
                  <span className="text-muted-foreground"> · {row.uri}</span>
                ) : null}
                {row.kind ? (
                  <span className="text-muted-foreground"> · {row.kind}</span>
                ) : null}
              </span>
            </div>
          ) : null,
        )}
      </div>

      {result.zones.length > 0 ? (
        <p className="text-xs text-muted-foreground">
          Zones: {result.zones.join(", ")}
        </p>
      ) : null}

      {result.hierarchy.length > 0 ? (
        <div className="overflow-x-auto rounded border bg-background/60">
          <table className="w-full text-left text-xs">
            <thead>
              <tr className="border-b text-muted-foreground">
                <th className="px-2 py-1.5 font-medium">Depth</th>
                <th className="px-2 py-1.5 font-medium">Name</th>
                <th className="px-2 py-1.5 font-medium">Kind</th>
              </tr>
            </thead>
            <tbody>
              {result.hierarchy.map((node) => (
                <tr key={node.id} className="border-b last:border-0">
                  <td className="px-2 py-1.5 tabular-nums">{node.depth}</td>
                  <td className="px-2 py-1.5">{node.name}</td>
                  <td className="px-2 py-1.5">{node.kind}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      ) : null}

      {result.zones_detail.length > 0 ? (
        <div className="overflow-x-auto rounded border bg-background/60">
          <p className="border-b px-2 py-1.5 text-xs font-medium">
            zones_detail
          </p>
          <table className="w-full text-left text-xs">
            <tbody>
              {result.zones_detail.map((zone) => (
                <tr key={zone.id} className="border-b last:border-0">
                  <td className="px-2 py-1.5 tabular-nums">{zone.depth}</td>
                  <td className="px-2 py-1.5">{zone.name}</td>
                  <td className="px-2 py-1.5">{zone.kind}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      ) : null}
    </div>
  )
}

function humanizeErrorCode(code: MapbaseError["code"]): string {
  switch (code) {
    case "unauthorized":
      return "Invalid API key"
    case "rate_limited":
      return "Rate limited — try again shortly"
    case "invalid_request":
      return "Invalid coordinates"
    case "not_found":
      return "No location at this point"
    case "not_implemented":
      return "Endpoint not yet available"
    case "network_error":
      return "Network error"
    default:
      return "Something went wrong"
  }
}