
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
leaflet-mapuiGeoJSON polygon fills graduated by regional metrics — GDP, density, quota attainment.
Also available for Vue ->$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/leaflet-map.json$npx shadcn@latest add https://uipkge.dev/r/react/leaflet-map.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/leaflet-map.json$bunx shadcn@latest add https://uipkge.dev/r/react/leaflet-map.jsonnpx shadcn@latest add @uipkge-react/leaflet-mapInstalls to:components/ui/leaflet-map/| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | 'default''muted''streets''outdoors''light''dark''satellite''satellite-streets''navigation-day''navigation-night''standard' | default | optional |
size | 'default''sm''lg''xl''full' | — | optional |
tileUrlCustom raster tile URL template — overrides `variant`. | string | — | optional |
tileAttributionAttribution HTML for a custom `tileUrl`. Defaults to the OpenStreetMap credit. | string | — | optional |
tileSubdomainsTile subdomains for a custom `tileUrl` ('abcd' or ['a','b']). | string | string[] | — | optional |
centerInitial [lng, lat] — Mapbox order, matching the `map` component. | [number, number] | — | optional |
zoom | number | 2 | optional |
minZoom | number | — | optional |
maxZoomCaps the map's max zoom. Defaults to the tile provider's own maxZoom. | number | — | optional |
navigationShow the zoom control. | boolean | true | optional |
navigationPositionPlacement of the zoom control ('top-left' | 'top-right' | 'bottom-left' | 'bottom-right'). | LeafletPosition | bottom-right | optional |
fullscreenShow the HTML5 fullscreen toggle button. | boolean | false | optional |
fullscreenPositionPlacement of the fullscreen button. Defaults to 'top-right'. | LeafletPosition | top-right | optional |
attributionShow tile credits behind a ⓘ button (reveals on hover/tap). Keep on — OSM/Esri tiles require attribution. | boolean | true | optional |
scrollWheelZoomWheel zoom. Set false for maps embedded in scrollable pages. | boolean | true | optional |
mutedDesaturate the tile pane to a quiet canvas (markers stay coloured). | boolean | false | optional |
onCreatedHands you the raw Leaflet Map once it is created. | (map: L.Map) => void | — | optional |
Type aliases from this item's source — use them to shape the data you pass in.
LeafletFlyToOptionsinterface LeafletFlyToOptions {
/** [lng, lat] — Mapbox order. */
center?: [number, number]
zoom?: number
/** Milliseconds (converted to Leaflet's seconds). */
duration?: number
}LeafletViewOptionsinterface LeafletViewOptions {
center?: [number, number]
zoom?: number
}LeafletMapRefinterface LeafletMapRef {
readonly map: L.Map | null
getMap(): L.Map | null
flyTo(options?: LeafletFlyToOptions): void
setView(options?: LeafletViewOptions): void
jumpTo(options?: LeafletViewOptions): void
fitBounds(bounds: [[number, number], [number, number]] | L.LatLngBoundsExpression, options?: L.FitBoundsOptions): void
panTo(center: [number, number]): void
zoomIn(): void
zoomOut(): void
resize(): void
}LeafletTilePresetinterface LeafletTilePreset {
/** Raster tile URL template ({z}/{x}/{y}, optional {s} subdomains + {r} retina). */
url: string
/** Required provider attribution HTML. */
attribution: string
subdomains?: string | string[]
maxZoom?: number
/** Optional label/boundary overlay composited above the base tiles. */
overlayUrl?: string
}'use client'
import * as React from 'react'
import { createPortal } from 'react-dom'
import type * as L from 'leaflet'
import { useTheme } from 'next-themes'
import { cn } from '@/lib/utils'
import {
leafletMapVariants,
LEAFLET_TILES,
LEAFLET_THEME_TILES,
type LeafletMapVariant,
type LeafletMapVariants,
type LeafletTilePreset,
} from './leaflet-map.variants'
import 'leaflet/dist/leaflet.css'
import './leaflet-map.css'
type LeafletModule = typeof import('leaflet')
let leafletPromise: Promise<LeafletModule> | null = null
/** Leaflet touches `window`/`document` at import time — load it lazily so SSR
* renders never evaluate the module. */
function loadLeaflet(): Promise<LeafletModule> {
if (!leafletPromise) leafletPromise = import('leaflet')
return leafletPromise
}
/** [lng, lat] (Mapbox order, matching the `map` component) -> Leaflet [lat, lng]. */
function toLatLng(c: [number, number]): L.LatLngExpression {
return [c[1], c[0]]
}
function toLatLngs(path: [number, number][] | [number, number][][]): L.LatLngExpression[] | L.LatLngExpression[][] {
if (!path.length) return []
return Array.isArray(path[0][0])
? (path as [number, number][][]).map((ring) => ring.map(toLatLng))
: (path as [number, number][]).map(toLatLng)
}
function toLatLngBounds(bounds: [[number, number], [number, number]]): L.LatLngBoundsExpression {
return [toLatLng(bounds[0]), toLatLng(bounds[1])] as L.LatLngBoundsExpression
}
type LeafletPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
/** Leaflet merges options by assignment, so an explicit `undefined` clobbers
* its defaults (e.g. `subdomains: 'abc'` -> crash, `icon: undefined` kills the
* default pin). Strip undefined keys before handing options to Leaflet. */
function defined<T extends object>(o: T): T {
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T
}
const LEAFLET_ICON_BASE = 'https://unpkg.com/[email protected]/dist/images'
let defaultIconFixed = false
/** Leaflet's default pin references image paths bundlers can't resolve. */
function fixDefaultLeafletIcon(L: LeafletModule) {
if (defaultIconFixed) return
defaultIconFixed = true
L.Icon.Default.mergeOptions({
iconRetinaUrl: `${LEAFLET_ICON_BASE}/marker-icon-2x.png`,
iconUrl: `${LEAFLET_ICON_BASE}/marker-icon.png`,
shadowUrl: `${LEAFLET_ICON_BASE}/marker-shadow.png`,
})
}
const LeafletMapContext = React.createContext<L.Map | null>(null)
const LeafletLayerContext = React.createContext<L.Layer | null>(null)
/** The L.Map instance from the enclosing <LeafletMap> — null until created. */
export function useLeafletMap() {
return React.useContext(LeafletMapContext)
}
export interface LeafletFlyToOptions {
/** [lng, lat] — Mapbox order. */
center?: [number, number]
zoom?: number
/** Milliseconds (converted to Leaflet's seconds). */
duration?: number
}
export interface LeafletViewOptions {
center?: [number, number]
zoom?: number
}
/** Imperative handle published by <LeafletMap> — mirrors the `map`
* component's MapRef surface: camera helpers plus the raw L.Map. */
export interface LeafletMapRef {
readonly map: L.Map | null
getMap(): L.Map | null
flyTo(options?: LeafletFlyToOptions): void
setView(options?: LeafletViewOptions): void
jumpTo(options?: LeafletViewOptions): void
fitBounds(bounds: [[number, number], [number, number]] | L.LatLngBoundsExpression, options?: L.FitBoundsOptions): void
panTo(center: [number, number]): void
zoomIn(): void
zoomOut(): void
resize(): void
}
export interface LeafletMapProps extends React.HTMLAttributes<HTMLDivElement> {
/** Named raster basemap preset ('streets' | 'outdoors' | 'satellite' | 'satellite-streets' | 'light' | 'dark' | 'navigation-day' | 'navigation-night' | 'standard' | 'muted' | 'default'). */
variant?: LeafletMapVariant
/** Height preset. Omit to size via `className` (blocks typically pass `size-full`). */
size?: LeafletMapVariants['size']
/** Custom raster tile URL template — overrides `variant`. */
tileUrl?: string
/** Attribution HTML for a custom `tileUrl`. Defaults to the OpenStreetMap credit. */
tileAttribution?: string
/** Tile subdomains for a custom `tileUrl` ('abcd' or ['a','b']). */
tileSubdomains?: string | string[]
/** Initial [lng, lat] — Mapbox order, matching the `map` component. */
center?: [number, number]
zoom?: number
minZoom?: number
/** Caps the map's max zoom. Defaults to the tile provider's own maxZoom. */
maxZoom?: number
/** Show the zoom control. */
navigation?: boolean
/** Placement of the zoom control ('top-left' | 'top-right' | 'bottom-left' | 'bottom-right'). */
navigationPosition?: LeafletPosition
/** Show the HTML5 fullscreen toggle button. */
fullscreen?: boolean
/** Placement of the fullscreen button. Defaults to 'top-right'. */
fullscreenPosition?: LeafletPosition
/** Show tile credits behind a ⓘ button (reveals on hover/tap). Keep on — OSM/Esri tiles require attribution. */
attribution?: boolean
/** Wheel zoom. Set false for maps embedded in scrollable pages. */
scrollWheelZoom?: boolean
/** Desaturate the tile pane to a quiet canvas (markers stay coloured). */
muted?: boolean
/** Hands you the raw Leaflet Map once it is created. */
onCreated?: (map: L.Map) => void
}
const LeafletMapComponent = React.forwardRef<LeafletMapRef, LeafletMapProps>(
(
{
className,
variant = 'default',
size,
tileUrl,
tileAttribution,
tileSubdomains,
center = [0, 20],
zoom = 2,
minZoom,
maxZoom,
navigation = true,
navigationPosition = 'bottom-right',
fullscreen = false,
fullscreenPosition = 'top-right',
attribution = true,
scrollWheelZoom = true,
muted = false,
onCreated,
children,
...props
},
ref,
) => {
const { resolvedTheme } = useTheme()
const [htmlDark, setHtmlDark] = React.useState(false)
const isDark = resolvedTheme === 'dark' || htmlDark
const isMuted = muted || variant === 'muted'
const resolvedTiles = React.useMemo<LeafletTilePreset>(() => {
if (tileUrl) {
return {
url: tileUrl,
attribution:
tileAttribution ??
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
subdomains: tileSubdomains,
maxZoom,
}
}
if (
variant &&
variant !== 'default' &&
variant !== 'muted' &&
LEAFLET_TILES[variant as keyof typeof LEAFLET_TILES]
) {
return LEAFLET_TILES[variant as keyof typeof LEAFLET_TILES]
}
return isDark ? LEAFLET_THEME_TILES.dark : LEAFLET_THEME_TILES.light
}, [tileUrl, tileAttribution, tileSubdomains, maxZoom, variant, isDark])
const [mounted, setMounted] = React.useState(false)
const [inView, setInView] = React.useState(false)
const [isFullscreen, setIsFullscreen] = React.useState(false)
const [canZoomIn, setCanZoomIn] = React.useState(true)
const [canZoomOut, setCanZoomOut] = React.useState(true)
const containerRef = React.useRef<HTMLDivElement>(null)
const mapElRef = React.useRef<HTMLDivElement>(null)
const mapRef = React.useRef<L.Map | null>(null)
const leafletRef = React.useRef<LeafletModule | null>(null)
const baseLayerRef = React.useRef<L.TileLayer | null>(null)
const overlayLayerRef = React.useRef<L.TileLayer | null>(null)
const [mapReady, setMapReady] = React.useState(false)
const [attributions, setAttributions] = React.useState<string[]>([])
const [showAttribution, setShowAttribution] = React.useState(false)
const syncZoomBounds = React.useCallback(() => {
const m = mapRef.current
if (!m) return
setCanZoomIn(m.getZoom() < m.getMaxZoom())
setCanZoomOut(m.getZoom() > m.getMinZoom())
}, [])
// Collect attribution strings from every layer (base tiles, overlays,
// custom LeafletTileLayers) — deduped, rendered by the ⓘ popover.
const collectAttributions = React.useCallback(() => {
const m = mapRef.current
if (!m) return
const seen = new Set<string>()
m.eachLayer((layer) => {
const a = (layer as L.TileLayer).options?.attribution
if (typeof a === 'string' && a) seen.add(a)
})
setAttributions([...seen])
}, [])
const applyTiles = React.useCallback((tiles: LeafletTilePreset) => {
const m = mapRef.current
const L = leafletRef.current
if (!m || !L) return
baseLayerRef.current?.remove()
baseLayerRef.current = null
overlayLayerRef.current?.remove()
overlayLayerRef.current = null
baseLayerRef.current = L.tileLayer(
tiles.url,
defined({
attribution: tiles.attribution,
subdomains: tiles.subdomains,
maxZoom: tiles.maxZoom ?? 19,
}),
)
baseLayerRef.current.addTo(m)
if (tiles.overlayUrl) {
overlayLayerRef.current = L.tileLayer(tiles.overlayUrl, { maxZoom: tiles.maxZoom ?? 19 })
overlayLayerRef.current.addTo(m)
}
}, [])
React.useEffect(() => {
const root = document.documentElement
const sync = () => setHtmlDark(root.classList.contains('dark'))
sync()
setMounted(true)
const obs = new MutationObserver(sync)
obs.observe(root, { attributes: true, attributeFilter: ['class'] })
const onFs = () => setIsFullscreen(Boolean(document.fullscreenElement))
document.addEventListener('fullscreenchange', onFs)
return () => {
obs.disconnect()
document.removeEventListener('fullscreenchange', onFs)
}
}, [])
React.useEffect(() => {
const el = containerRef.current
if (!el || typeof IntersectionObserver === 'undefined') {
setInView(true)
return
}
const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
rootMargin: '160px',
threshold: 0.01,
})
io.observe(el)
return () => io.disconnect()
}, [])
React.useEffect(() => {
const el = containerRef.current
if (!el || typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(() => mapRef.current?.invalidateSize())
ro.observe(el)
return () => ro.disconnect()
}, [mounted])
React.useEffect(() => {
if (!mounted || !inView || mapRef.current) return
let cancelled = false
loadLeaflet().then((L) => {
if (cancelled || mapRef.current || !mapElRef.current) return
leafletRef.current = L
fixDefaultLeafletIcon(L)
const tiles = resolvedTiles
const m = L.map(
mapElRef.current,
defined({
center: toLatLng(center),
zoom,
minZoom,
maxZoom: maxZoom ?? tiles.maxZoom,
zoomControl: false,
attributionControl: false,
scrollWheelZoom,
}),
)
mapRef.current = m
applyTiles(tiles)
m.on('zoomend', syncZoomBounds)
syncZoomBounds()
m.on('layeradd layerremove', collectAttributions)
collectAttributions()
setMapReady(true)
onCreated?.(m)
})
return () => {
cancelled = true
}
// Map creation is mount-once; later prop changes flow through the
// watchers below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mounted, inView])
React.useEffect(() => {
if (mapRef.current) applyTiles(resolvedTiles)
}, [resolvedTiles, applyTiles])
React.useEffect(() => {
const m = mapRef.current
if (m && center) m.setView(toLatLng(center), zoom)
}, [center, zoom])
React.useEffect(() => {
if (!attribution) setShowAttribution(false)
}, [attribution])
React.useEffect(() => {
const m = mapRef.current
if (!m) return
if (scrollWheelZoom) m.scrollWheelZoom.enable()
else m.scrollWheelZoom.disable()
}, [scrollWheelZoom])
React.useEffect(
() => () => {
mapRef.current?.remove()
mapRef.current = null
},
[],
)
React.useImperativeHandle(
ref,
() => ({
get map() {
return mapRef.current
},
getMap: () => mapRef.current,
flyTo: (options = {}) => {
const m = mapRef.current
if (!m) return
m.flyTo(options.center ? toLatLng(options.center) : m.getCenter(), options.zoom ?? m.getZoom(), {
duration: (options.duration ?? 800) / 1000,
})
},
setView: (options = {}) => {
const m = mapRef.current
if (!m) return
m.setView(options.center ? toLatLng(options.center) : m.getCenter(), options.zoom ?? m.getZoom())
},
jumpTo: (options = {}) => {
const m = mapRef.current
if (!m) return
m.setView(options.center ? toLatLng(options.center) : m.getCenter(), options.zoom ?? m.getZoom(), {
animate: false,
})
},
fitBounds: (bounds, options) =>
mapRef.current?.fitBounds(toLatLngBounds(bounds as [[number, number], [number, number]]), options),
panTo: (c) => mapRef.current?.panTo(toLatLng(c)),
zoomIn: () => mapRef.current?.zoomIn(),
zoomOut: () => mapRef.current?.zoomOut(),
resize: () => mapRef.current?.invalidateSize(),
}),
[mapReady],
)
// Zoom/fullscreen chrome is plain HTML overlaid on the map (like Mapbox's
// controls) — a corner stack per occupied corner, zoom group above fullscreen.
const cornerClasses: Record<LeafletPosition, string> = {
'top-left': 'left-3 top-3',
'top-right': 'right-3 top-3',
// above the ⓘ button (bottom-left) when credits are shown
'bottom-left': attribution && attributions.length ? 'bottom-9 left-3' : 'bottom-3 left-3',
'bottom-right': 'bottom-3 right-3',
}
const cornerOrder: LeafletPosition[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right']
const navPosition = navigationPosition ?? 'bottom-right'
const fsPosition = fullscreenPosition ?? 'top-right'
return (
<div
ref={containerRef}
data-uipkge=""
data-slot="leaflet-map"
data-variant={variant}
data-muted={isMuted}
className={cn(leafletMapVariants({ variant, ...(size ? { size } : {}) }), className)}
{...props}
>
{/* className must stay constant — React rewrites the attribute when
the value changes and would wipe the classes Leaflet adds
(leaflet-container, leaflet-touch, …). The empty pre-mount div is
invisible regardless. */}
<div ref={mapElRef} className="size-full" />
{cornerOrder.map((corner) => {
const showZoom = mapReady && navigation && navPosition === corner
const showFullscreen = mapReady && fullscreen && fsPosition === corner
if (!showZoom && !showFullscreen) return null
return (
<div key={corner} className={cn('absolute z-[1000] flex flex-col gap-2.5', cornerClasses[corner])}>
{showZoom && (
<div className="border-border bg-card divide-border flex flex-col divide-y overflow-hidden rounded-lg border shadow-sm">
<button
type="button"
className="text-muted-foreground hover:bg-muted flex size-8 items-center justify-center transition-colors disabled:pointer-events-none disabled:opacity-40"
aria-label="Zoom in"
disabled={!canZoomIn}
onClick={() => mapRef.current?.zoomIn()}
>
<svg className="size-full" viewBox="0 0 29 29" fill="currentColor" aria-hidden="true">
<path d="M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z" />
</svg>
</button>
<button
type="button"
className="text-muted-foreground hover:bg-muted flex size-8 items-center justify-center transition-colors disabled:pointer-events-none disabled:opacity-40"
aria-label="Zoom out"
disabled={!canZoomOut}
onClick={() => mapRef.current?.zoomOut()}
>
<svg className="size-full" viewBox="0 0 29 29" fill="currentColor" aria-hidden="true">
<path d="M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z" />
</svg>
</button>
</div>
)}
{showFullscreen && (
<button
type="button"
className="border-border bg-card text-muted-foreground hover:bg-muted flex size-8 items-center justify-center rounded-lg border shadow-sm transition-colors"
aria-label="Toggle fullscreen"
onClick={() => {
const el = containerRef.current
if (!el) return
if (document.fullscreenElement) document.exitFullscreen()
else el.requestFullscreen?.()
}}
>
<svg className="size-full" viewBox="0 0 29 29" fill="currentColor" aria-hidden="true">
{isFullscreen ? (
<path d="M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z" />
) : (
<path d="M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z" />
)}
</svg>
</button>
)}
</div>
)
})}
{/* Tile credits behind a Mapbox-style ⓘ button: hover reveals on
desktop, tap toggles on touch. Keep visible — OSM/Esri tiles
require credit. */}
{mapReady && attribution && attributions.length > 0 && (
<div className="group absolute bottom-3 left-3 z-[1000] flex flex-col items-start gap-1.5">
<div
role="note"
className={cn(
'border-border bg-popover text-popover-foreground max-w-64 rounded-md border px-2.5 py-1.5 text-[11px] leading-relaxed shadow-md transition-opacity [&_a]:underline',
showAttribution
? 'visible opacity-100'
: 'invisible opacity-0 group-hover:visible group-hover:opacity-100',
)}
dangerouslySetInnerHTML={{ __html: attributions.join(' | ') }}
/>
<button
type="button"
className="border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground flex size-4 items-center justify-center rounded-full border shadow-xs transition-colors"
aria-label="Map data attribution"
aria-expanded={showAttribution}
onClick={() => setShowAttribution((v) => !v)}
>
<svg
className="size-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</svg>
</button>
</div>
)}
{mapReady && <LeafletMapContext.Provider value={mapRef.current}>{children}</LeafletMapContext.Provider>}
</div>
)
},
)
LeafletMapComponent.displayName = 'LeafletMap'
/** Shared layer lifecycle: build once the enclosing map exists, add to it,
* remove on unmount, and republish through LeafletLayerContext so nested
* popups/tooltips can bind. */
function useLeafletLayer<T extends L.Layer>(build: (L: LeafletModule) => T): T | null {
const map = useLeafletMap()
const [layer, setLayer] = React.useState<T | null>(null)
const buildRef = React.useRef(build)
buildRef.current = build
React.useEffect(() => {
if (!map) return
let cancelled = false
let instance: T | null = null
loadLeaflet().then((L) => {
if (cancelled) return
instance = buildRef.current(L)
instance.addTo(map)
setLayer(instance)
})
return () => {
cancelled = true
instance?.remove()
setLayer(null)
}
}, [map])
return layer
}
function useLatest<T>(value: T) {
const r = React.useRef(value)
r.current = value
return r
}
type MarkerAnchor =
| 'center'
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right'
export interface LeafletMarkerProps {
/** [lng, lat] — Mapbox order, matching the `map` component's MapMarker. */
lngLat: [number, number]
/** Which edge/corner of the marker content sits on the coordinate. */
anchor?: MarkerAnchor
draggable?: boolean
opacity?: number
zIndexOffset?: number
title?: string
alt?: string
onClick?: (e: L.LeafletMouseEvent) => void
children?: React.ReactNode
}
/** Marker with real-DOM custom content: children portal into a div icon, so
* event handlers and state keep working. LeafletPopup/LeafletTooltip children
* bind to the marker instead of becoming icon content. */
const LeafletMarkerComponent = ({
lngLat,
anchor = 'center',
draggable,
opacity,
zIndexOffset,
title,
alt,
onClick,
children,
}: LeafletMarkerProps) => {
const map = useLeafletMap()
const [iconEl, setIconEl] = React.useState<HTMLElement | null>(null)
const [marker, setMarker] = React.useState<L.Marker | null>(null)
const onClickRef = useLatest(onClick)
const kids = React.Children.toArray(children)
const overlays = kids.filter(
(k) => React.isValidElement(k) && (k.type === LeafletPopupComponent || k.type === LeafletTooltipComponent),
)
const htmlKids = kids.filter((k) => !overlays.includes(k))
const hasHtml = htmlKids.length > 0
const hasHtmlRef = useLatest(hasHtml)
React.useEffect(() => {
if (!map) return
let cancelled = false
let m: L.Marker | null = null
loadLeaflet().then((L) => {
if (cancelled) return
let el: HTMLElement | undefined
if (hasHtmlRef.current) {
el = document.createElement('div')
el.className = 'uipkge-leaflet-anchor'
el.dataset.anchor = anchor
}
m = L.marker(
toLatLng(lngLat),
defined({
icon: el ? L.divIcon({ className: 'uipkge-leaflet-div-icon', html: el }) : undefined,
interactive: true,
draggable,
opacity,
zIndexOffset,
title,
alt,
}),
)
m.on('click', (ev) => onClickRef.current?.(ev))
m.addTo(map)
setMarker(m)
if (el) setIconEl(el)
})
return () => {
cancelled = true
m?.remove()
setMarker(null)
setIconEl(null)
}
// Marker builds once per map; prop changes flow through the effects below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [map])
React.useEffect(() => {
marker?.setLatLng(toLatLng(lngLat))
}, [marker, lngLat?.[0], lngLat?.[1]])
React.useEffect(() => {
if (opacity !== undefined) marker?.setOpacity(opacity)
}, [marker, opacity])
React.useEffect(() => {
if (zIndexOffset !== undefined) marker?.setZIndexOffset(zIndexOffset)
}, [marker, zIndexOffset])
return (
<LeafletLayerContext.Provider value={marker}>
{overlays}
{iconEl ? createPortal(htmlKids, iconEl) : null}
</LeafletLayerContext.Provider>
)
}
LeafletMarkerComponent.displayName = 'LeafletMarker'
export interface LeafletPopupProps {
/** [lng, lat] — standalone popup on the map. Omit inside a layer to bind to it. */
lngLat?: [number, number]
maxWidth?: number
/** Minimum popup width. Defaults to 200 — keeps card-style content from collapsing narrow. */
minWidth?: number
offset?: [number, number]
className?: string
autoClose?: boolean
closeOnClick?: boolean
closeButton?: boolean
keepInView?: boolean
children?: React.ReactNode
}
/** Popup bound to the nearest ancestor layer, or opened standalone at `lngLat`.
* Children portal into the popup's real content node. */
const LeafletPopupComponent = ({ lngLat, minWidth = 200, children, ...options }: LeafletPopupProps) => {
const map = useLeafletMap()
const parent = React.useContext(LeafletLayerContext)
const [contentEl, setContentEl] = React.useState<HTMLElement | null>(null)
const optionsRef = useLatest(defined({ ...options, minWidth }))
React.useEffect(() => {
if (!map) return
const el = document.createElement('div')
el.className = 'uipkge-leaflet-popup-src'
setContentEl(el)
let cancelled = false
let popup: L.Popup | null = null
let boundTo: L.Layer | null = null
loadLeaflet().then((L) => {
if (cancelled) return
if (parent) {
boundTo = parent
parent.bindPopup(el, optionsRef.current as L.PopupOptions)
} else if (lngLat) {
popup = L.popup(optionsRef.current as L.PopupOptions)
.setLatLng(toLatLng(lngLat))
.setContent(el)
popup.openOn(map)
}
})
return () => {
cancelled = true
try {
boundTo?.unbindPopup()
popup?.remove()
} catch {
/* map already destroyed */
}
setContentEl(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [map, parent])
return contentEl ? createPortal(children, contentEl) : null
}
LeafletPopupComponent.displayName = 'LeafletPopup'
export interface LeafletTooltipProps {
/** [lng, lat] — standalone tooltip on the map. Omit inside a layer to bind to it. */
lngLat?: [number, number]
offset?: [number, number]
direction?: 'top' | 'bottom' | 'left' | 'right' | 'center' | 'auto'
permanent?: boolean
sticky?: boolean
opacity?: number
className?: string
interactive?: boolean
children?: React.ReactNode
}
/** Tooltip bound to the nearest ancestor layer, or standalone at `lngLat`. */
const LeafletTooltipComponent = ({ lngLat, children, ...options }: LeafletTooltipProps) => {
const map = useLeafletMap()
const parent = React.useContext(LeafletLayerContext)
const [contentEl, setContentEl] = React.useState<HTMLElement | null>(null)
const optionsRef = useLatest(options)
React.useEffect(() => {
if (!map) return
const el = document.createElement('div')
el.className = 'uipkge-leaflet-tooltip-src'
setContentEl(el)
let cancelled = false
let tooltip: L.Tooltip | null = null
let boundTo: L.Layer | null = null
loadLeaflet().then((L) => {
if (cancelled) return
if (parent) {
boundTo = parent
parent.bindTooltip(el, optionsRef.current as L.TooltipOptions)
} else if (lngLat) {
tooltip = L.tooltip(optionsRef.current as L.TooltipOptions)
.setLatLng(toLatLng(lngLat))
.setContent(el)
tooltip.addTo(map)
}
})
return () => {
cancelled = true
try {
boundTo?.unbindTooltip()
tooltip?.remove()
} catch {
/* map already destroyed */
}
setContentEl(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [map, parent])
return contentEl ? createPortal(children, contentEl) : null
}
LeafletTooltipComponent.displayName = 'LeafletTooltip'
export interface LeafletPathProps {
color?: string
weight?: number
opacity?: number
lineCap?: 'butt' | 'round' | 'square'
lineJoin?: 'miter' | 'round' | 'bevel'
dashArray?: string | number[]
dashOffset?: string
fill?: boolean
fillColor?: string
fillOpacity?: number
className?: string
onClick?: (e: L.LeafletMouseEvent) => void
children?: React.ReactNode
}
export interface LeafletPolylineProps extends LeafletPathProps {
/** Path points as [lng, lat][] — or [lng, lat][][] for multi-part lines. */
lngLatPath: [number, number][] | [number, number][][]
smoothFactor?: number
noClip?: boolean
}
const LeafletPolylineComponent = ({ lngLatPath, onClick, children, ...opts }: LeafletPolylineProps) => {
const onClickRef = useLatest(onClick)
const optsRef = useLatest(opts)
const layer = useLeafletLayer<L.Polyline>((L) => {
const l = L.polyline(toLatLngs(lngLatPath) as L.LatLngExpression[], { ...optsRef.current, interactive: true })
l.on('click', (ev) => onClickRef.current?.(ev))
return l
})
React.useEffect(() => {
layer?.setLatLngs(toLatLngs(lngLatPath) as L.LatLngExpression[])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layer, lngLatPath])
React.useEffect(() => {
layer?.setStyle({ ...opts, interactive: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layer, opts.color, opts.weight, opts.opacity, opts.dashArray, opts.dashOffset, opts.lineCap, opts.lineJoin])
return <LeafletLayerContext.Provider value={layer}>{children}</LeafletLayerContext.Provider>
}
LeafletPolylineComponent.displayName = 'LeafletPolyline'
export interface LeafletPolygonProps extends LeafletPathProps {
/** Ring points as [lng, lat][] — or [lng, lat][][] for holes/multi-polygons. */
lngLatPath: [number, number][] | [number, number][][]
}
const LeafletPolygonComponent = ({ lngLatPath, onClick, children, ...opts }: LeafletPolygonProps) => {
const onClickRef = useLatest(onClick)
const optsRef = useLatest(opts)
const layer = useLeafletLayer<L.Polygon>((L) => {
const l = L.polygon(toLatLngs(lngLatPath) as L.LatLngExpression[], { ...optsRef.current, interactive: true })
l.on('click', (ev) => onClickRef.current?.(ev))
return l
})
React.useEffect(() => {
layer?.setLatLngs(toLatLngs(lngLatPath) as L.LatLngExpression[])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layer, lngLatPath])
React.useEffect(() => {
layer?.setStyle({ ...opts, interactive: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layer, opts.color, opts.weight, opts.opacity, opts.fill, opts.fillColor, opts.fillOpacity, opts.dashArray])
return <LeafletLayerContext.Provider value={layer}>{children}</LeafletLayerContext.Provider>
}
LeafletPolygonComponent.displayName = 'LeafletPolygon'
export interface LeafletCircleProps extends LeafletPathProps {
/** [lng, lat] — Mapbox order. */
center: [number, number]
/** Radius in meters. */
radius?: number
}
const LeafletCircleComponent = ({ center, radius, onClick, children, ...opts }: LeafletCircleProps) => {
const onClickRef = useLatest(onClick)
const optsRef = useLatest(opts)
const layer = useLeafletLayer<L.Circle>((L) => {
const l = L.circle(toLatLng(center), { ...optsRef.current, interactive: true, radius })
l.on('click', (ev) => onClickRef.current?.(ev))
return l
})
React.useEffect(() => {
layer?.setLatLng(toLatLng(center))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layer, center?.[0], center?.[1]])
React.useEffect(() => {
if (radius !== undefined) layer?.setRadius(radius)
}, [layer, radius])
React.useEffect(() => {
layer?.setStyle({ ...opts, interactive: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layer, opts.color, opts.weight, opts.opacity, opts.fill, opts.fillColor, opts.fillOpacity, opts.dashArray])
return <LeafletLayerContext.Provider value={layer}>{children}</LeafletLayerContext.Provider>
}
LeafletCircleComponent.displayName = 'LeafletCircle'
export interface LeafletCircleMarkerProps extends LeafletPathProps {
/** [lng, lat] — Mapbox order. */
center: [number, number]
/** Radius in pixels. */
radius?: number
}
const LeafletCircleMarkerComponent = ({ center, radius, onClick, children, ...opts }: LeafletCircleMarkerProps) => {
const onClickRef = useLatest(onClick)
const optsRef = useLatest(opts)
const layer = useLeafletLayer<L.CircleMarker>((L) => {
const l = L.circleMarker(toLatLng(center), { ...optsRef.current, interactive: true, radius })
l.on('click', (ev) => onClickRef.current?.(ev))
return l
})
React.useEffect(() => {
layer?.setLatLng(toLatLng(center))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layer, center?.[0], center?.[1]])
React.useEffect(() => {
if (radius !== undefined) layer?.setRadius(radius)
}, [layer, radius])
React.useEffect(() => {
layer?.setStyle({ ...opts, interactive: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layer, opts.color, opts.weight, opts.opacity, opts.fill, opts.fillColor, opts.fillOpacity, opts.dashArray])
return <LeafletLayerContext.Provider value={layer}>{children}</LeafletLayerContext.Provider>
}
LeafletCircleMarkerComponent.displayName = 'LeafletCircleMarker'
export interface LeafletGeoJsonProps {
/** GeoJSON FeatureCollection / Feature / geometry. */
geojson: GeoJSON.GeoJSON
/** Leaflet GeoJSON options: `style`, `pointToLayer`, `onEachFeature`, `filter`, `coordsToLatLng`. */
options?: L.GeoJSONOptions
onClick?: (e: L.LeafletMouseEvent) => void
children?: React.ReactNode
}
const LeafletGeoJsonComponent = ({ geojson, options, onClick, children }: LeafletGeoJsonProps) => {
const onClickRef = useLatest(onClick)
const optionsRef = useLatest(options)
const layer = useLeafletLayer<L.GeoJSON>((L) => {
const l = L.geoJSON(geojson as any, optionsRef.current)
l.on('click', (ev) => onClickRef.current?.(ev))
return l
})
React.useEffect(() => {
if (!layer || !geojson) return
layer.clearLayers()
layer.addData(geojson as any)
}, [layer, geojson])
return <LeafletLayerContext.Provider value={layer}>{children}</LeafletLayerContext.Provider>
}
LeafletGeoJsonComponent.displayName = 'LeafletGeoJson'
export interface LeafletTileLayerProps {
/** Raster tile URL template ({z}/{x}/{y}, optional {s} subdomains + {r} retina). */
url: string
attribution?: string
subdomains?: string | string[]
minZoom?: number
maxZoom?: number
opacity?: number
zIndex?: number
tms?: boolean
}
const LeafletTileLayerComponent = ({
url,
attribution,
subdomains,
minZoom,
maxZoom,
opacity,
zIndex,
tms,
}: LeafletTileLayerProps) => {
const optsRef = useLatest({ attribution, subdomains, minZoom, maxZoom, opacity, zIndex, tms })
const layer = useLeafletLayer<L.TileLayer>((L) => L.tileLayer(url, defined(optsRef.current)))
React.useEffect(() => {
if (url) layer?.setUrl(url)
}, [layer, url])
React.useEffect(() => {
if (opacity !== undefined) layer?.setOpacity(opacity)
}, [layer, opacity])
React.useEffect(() => {
if (zIndex !== undefined) layer?.setZIndex(zIndex)
}, [layer, zIndex])
return null
}
LeafletTileLayerComponent.displayName = 'LeafletTileLayer'
export {
LeafletMapComponent as LeafletMap,
LeafletMarkerComponent as LeafletMarker,
LeafletPopupComponent as LeafletPopup,
LeafletTooltipComponent as LeafletTooltip,
LeafletPolylineComponent as LeafletPolyline,
LeafletPolygonComponent as LeafletPolygon,
LeafletCircleComponent as LeafletCircle,
LeafletCircleMarkerComponent as LeafletCircleMarker,
LeafletGeoJsonComponent as LeafletGeoJson,
LeafletTileLayerComponent as LeafletTileLayer,
}
import { cva, type VariantProps } from 'class-variance-authority'
export type LeafletMapVariant =
| 'default'
| 'muted'
| 'streets'
| 'outdoors'
| 'light'
| 'dark'
| 'satellite'
| 'satellite-streets'
| 'navigation-day'
| 'navigation-night'
| 'standard'
export interface LeafletTilePreset {
/** Raster tile URL template ({z}/{x}/{y}, optional {s} subdomains + {r} retina). */
url: string
/** Required provider attribution HTML. */
attribution: string
subdomains?: string | string[]
maxZoom?: number
/** Optional label/boundary overlay composited above the base tiles. */
overlayUrl?: string
}
const OSM_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
const TOPO_ATTR = `${OSM_ATTR} | map style: © <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)`
const ESRI_ATTR = 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community'
const OSM_STANDARD: LeafletTilePreset = {
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: OSM_ATTR,
maxZoom: 19,
}
// CARTO basemaps moved behind an API key — Esri Canvas/Street services stay key-free.
const ESRI_LIGHT: LeafletTilePreset = {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',
attribution: ESRI_ATTR,
maxZoom: 16,
overlayUrl:
'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}',
}
const ESRI_DARK: LeafletTilePreset = {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}',
attribution: ESRI_ATTR,
maxZoom: 16,
overlayUrl:
'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Reference/MapServer/tile/{z}/{y}/{x}',
}
const ESRI_STREETS: LeafletTilePreset = {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}',
attribution: ESRI_ATTR,
maxZoom: 19,
}
const ESRI_SATELLITE: LeafletTilePreset = {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: ESRI_ATTR,
maxZoom: 19,
}
export const LEAFLET_TILES: Record<Exclude<LeafletMapVariant, 'default' | 'muted'>, LeafletTilePreset> = {
streets: OSM_STANDARD,
standard: OSM_STANDARD,
outdoors: {
url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
attribution: TOPO_ATTR,
subdomains: 'abc',
maxZoom: 17,
},
light: ESRI_LIGHT,
dark: ESRI_DARK,
satellite: ESRI_SATELLITE,
'satellite-streets': {
...ESRI_SATELLITE,
overlayUrl:
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
},
'navigation-day': ESRI_STREETS,
'navigation-night': ESRI_DARK,
}
/** Theme-aware default tiles: Esri light/dark canvas following the app theme. */
export const LEAFLET_THEME_TILES = { light: ESRI_LIGHT, dark: ESRI_DARK }
export const leafletMapVariants = cva('relative size-full overflow-hidden bg-muted isolate', {
variants: {
variant: {
default: '',
muted: '',
streets: '',
outdoors: '',
light: '',
dark: '',
satellite: '',
'satellite-streets': '',
'navigation-day': '',
'navigation-night': '',
standard: '',
},
size: {
default: 'h-96 w-full',
sm: 'h-64 w-full',
lg: 'h-[500px] w-full',
xl: 'h-[650px] w-full',
full: 'size-full',
},
},
defaultVariants: {
variant: 'default',
},
})
export type LeafletMapVariants = VariantProps<typeof leafletMapVariants>
export {
LeafletMap,
LeafletMarker,
LeafletPopup,
LeafletTooltip,
LeafletPolyline,
LeafletPolygon,
LeafletCircle,
LeafletCircleMarker,
LeafletGeoJson,
LeafletTileLayer,
useLeafletMap,
type LeafletMapProps,
type LeafletMapRef,
type LeafletFlyToOptions,
type LeafletViewOptions,
type LeafletMarkerProps,
type LeafletPopupProps,
type LeafletTooltipProps,
type LeafletPathProps,
type LeafletPolylineProps,
type LeafletPolygonProps,
type LeafletCircleProps,
type LeafletCircleMarkerProps,
type LeafletGeoJsonProps,
type LeafletTileLayerProps,
} from './leaflet-map'
export {
leafletMapVariants,
LEAFLET_TILES,
LEAFLET_THEME_TILES,
type LeafletMapVariant,
type LeafletMapVariants,
type LeafletTilePreset,
} from './leaflet-map.variants'
/**
* Leaflet map chrome — re-skins Leaflet's controls, popups, and tooltips to the
* design-system tokens so they sit naturally on light and dark surfaces.
* Global (Leaflet renders this chrome outside the component subtree). Import
* once, e.g. in your root CSS: `@import './components/ui/leaflet-map/leaflet-map.css';`
*/
.leaflet-container {
font: inherit;
background: var(--muted);
}
.leaflet-bar {
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.leaflet-bar a,
.leaflet-bar a:hover {
background: var(--card);
color: var(--foreground);
border-bottom-color: var(--border);
}
.leaflet-bar a:hover {
background: var(--muted);
}
.leaflet-bar a.leaflet-disabled {
background: var(--card);
color: var(--muted-foreground);
}
.leaflet-popup-content-wrapper {
background: var(--popover);
color: var(--popover-foreground);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
}
.leaflet-popup-content {
margin: var(--spacing-2, 8px) var(--spacing-3, 12px);
font: inherit;
line-height: 1.5;
}
.leaflet-popup-tip {
background: var(--popover);
border: 1px solid var(--border);
box-shadow: none;
}
.leaflet-popup-close-button {
color: var(--muted-foreground) !important;
}
.leaflet-tooltip {
background: var(--popover);
color: var(--popover-foreground);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
font: inherit;
}
.leaflet-tooltip-top:before {
border-top-color: var(--border);
}
.leaflet-tooltip-bottom:before {
border-bottom-color: var(--border);
}
/* `muted` variant — desaturate only the tile pane so overlaid data keeps its
* colour. */
[data-slot='leaflet-map'][data-muted='true'] .leaflet-tile-pane {
filter: grayscale(55%) contrast(0.92);
}
/* Custom marker icons: Leaflet wraps our element in its own icon div; keep it
* unstyled so slot content controls the look. DivIcon forces a 12×12 inline
* box plus anchor margins — override so the icon sizes to its content and the
* inner anchor transform does the positioning. */
.uipkge-leaflet-div-icon {
background: transparent;
border: none;
width: auto !important;
height: auto !important;
margin: 0 !important;
}
/* Marker slot content is a real DOM element moved into the icon. It stays
* hidden while parked inside the component tree, and the anchor transform
* decides which point of the content sits on the coordinate. */
.uipkge-leaflet-anchor {
display: none;
}
.leaflet-marker-icon .uipkge-leaflet-anchor {
display: block;
/* shrink-to-fit inside the abspos icon resolves to min-content and wraps
* labels — force natural content width instead */
width: max-content;
}
.uipkge-leaflet-anchor[data-anchor='center'] {
transform: translate(-50%, -50%);
}
.uipkge-leaflet-anchor[data-anchor='top'] {
transform: translate(-50%, 0);
}
.uipkge-leaflet-anchor[data-anchor='bottom'] {
transform: translate(-50%, -100%);
}
.uipkge-leaflet-anchor[data-anchor='left'] {
transform: translate(0, -50%);
}
.uipkge-leaflet-anchor[data-anchor='right'] {
transform: translate(-100%, -50%);
}
.uipkge-leaflet-anchor[data-anchor='top-left'] {
transform: translate(0, 0);
}
.uipkge-leaflet-anchor[data-anchor='top-right'] {
transform: translate(-100%, 0);
}
.uipkge-leaflet-anchor[data-anchor='bottom-left'] {
transform: translate(0, -100%);
}
.uipkge-leaflet-anchor[data-anchor='bottom-right'] {
transform: translate(-100%, -100%);
}
/* Popup/tooltip slot sources are real DOM moved into the overlay's content
* node when it opens. */
.uipkge-leaflet-popup-src,
.uipkge-leaflet-tooltip-src {
display: none;
}
.leaflet-popup-content .uipkge-leaflet-popup-src,
.leaflet-tooltip .uipkge-leaflet-tooltip-src {
display: block;
}
Raw manifest:https://uipkge.dev/r/react/leaflet-choropleth-map-chart.json