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 →
/v1/autocomplete
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.jsonCopies 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,
},
})
}