"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/new-york-v4/ui/button"
import { ButtonGroup } from "@/registry/new-york-v4/ui/button-group"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/new-york-v4/ui/dropdown-menu"
import type { CheckboxItem } from "@radix-ui/react-dropdown-menu"
import type {
Circle,
CircleMarker,
DivIconOptions,
Draw,
DrawEvents,
DrawMap,
DrawOptions,
EditToolbar,
ErrorEvent,
FeatureGroup,
LatLngExpression,
LayerGroup,
Map as LeafletMap,
LocateOptions,
LocationEvent,
Marker,
PointExpression,
Polygon,
Polyline,
Popup,
Rectangle,
TileLayer,
Tooltip,
} from "leaflet"
import "leaflet-draw/dist/leaflet.draw.css"
import "leaflet/dist/leaflet.css"
import {
CircleIcon,
LayersIcon,
LoaderCircleIcon,
MapPinIcon,
MinusIcon,
NavigationIcon,
PenLineIcon,
PentagonIcon,
PlusIcon,
SquareIcon,
Trash2Icon,
Undo2Icon,
WaypointsIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import dynamic from "next/dynamic"
import {
createContext,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
type Ref,
} from "react"
import { renderToString } from "react-dom/server"
import {
useMap,
useMapEvents,
type CircleMarkerProps,
type CircleProps,
type LayerGroupProps,
type MapContainerProps,
type MarkerProps,
type PolygonProps,
type PolylineProps,
type PopupProps,
type RectangleProps,
type TileLayerProps,
type TooltipProps,
} from "react-leaflet"
const LeafletMapContainer = dynamic(
async () => (await import("react-leaflet")).MapContainer,
{ ssr: false }
)
const LeafletTileLayer = dynamic(
async () => (await import("react-leaflet")).TileLayer,
{ ssr: false }
)
const LeafletMarker = dynamic(
async () => (await import("react-leaflet")).Marker,
{ ssr: false }
)
const LeafletPopup = dynamic(
async () => (await import("react-leaflet")).Popup,
{ ssr: false }
)
const LeafletTooltip = dynamic(
async () => (await import("react-leaflet")).Tooltip,
{ ssr: false }
)
const LeafletCircle = dynamic(
async () => (await import("react-leaflet")).Circle,
{ ssr: false }
)
const LeafletCircleMarker = dynamic(
async () => (await import("react-leaflet")).CircleMarker,
{ ssr: false }
)
const LeafletPolyline = dynamic(
async () => (await import("react-leaflet")).Polyline,
{ ssr: false }
)
const LeafletPolygon = dynamic(
async () => (await import("react-leaflet")).Polygon,
{ ssr: false }
)
const LeafletRectangle = dynamic(
async () => (await import("react-leaflet")).Rectangle,
{ ssr: false }
)
const LeafletLayerGroup = dynamic(
async () => (await import("react-leaflet")).LayerGroup,
{ ssr: false }
)
const LeafletFeatureGroup = dynamic(
async () => (await import("react-leaflet")).FeatureGroup,
{ ssr: false }
)
function Map({
zoom = 15,
className,
...props
}: Omit<MapContainerProps, "zoomControl"> & {
center: LatLngExpression
ref?: Ref<LeafletMap>
}) {
return (
<LeafletMapContainer
zoom={zoom}
attributionControl={false}
zoomControl={false}
className={cn(
"z-50 size-full min-h-96 flex-1 rounded-md",
className
)}
{...props}
/>
)
}
interface MapTileLayerOption {
name: string
url: string
attribution?: string
}
interface MapLayerGroupOption
extends Pick<React.ComponentProps<typeof CheckboxItem>, "disabled"> {
name: string
}
interface MapLayersContextType {
registerTileLayer: (layer: MapTileLayerOption) => void
tileLayers: MapTileLayerOption[]
selectedTileLayer: string
setSelectedTileLayer: (name: string) => void
registerLayerGroup: (layer: MapLayerGroupOption) => void
layerGroups: MapLayerGroupOption[]
activeLayerGroups: string[]
setActiveLayerGroups: (names: string[]) => void
}
const MapLayersContext = createContext<MapLayersContextType | null>(null)
function useMapLayersContext() {
return useContext(MapLayersContext)
}
function MapTileLayer({
name = "Default",
url,
attribution,
darkUrl,
darkAttribution,
...props
}: Partial<TileLayerProps> & {
name?: string
darkUrl?: string
darkAttribution?: string
ref?: Ref<TileLayer>
}) {
const map = useMap()
if (map.attributionControl) {
map.attributionControl.setPrefix("")
}
const context = useContext(MapLayersContext)
const DEFAULT_URL =
"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"
const DEFAULT_DARK_URL =
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"
const { resolvedTheme } = useTheme()
const resolvedUrl =
resolvedTheme === "dark"
? (darkUrl ?? url ?? DEFAULT_DARK_URL)
: (url ?? DEFAULT_URL)
const resolvedAttribution =
resolvedTheme === "dark" && darkAttribution
? darkAttribution
: (attribution ??
'© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <a href="https://carto.com/attributions">CARTO</a>')
useEffect(() => {
if (context) {
context.registerTileLayer({
name,
url: resolvedUrl,
attribution: resolvedAttribution,
})
}
}, [context, name, url, attribution])
if (context && context.selectedTileLayer !== name) {
return null
}
return (
<LeafletTileLayer
url={resolvedUrl}
attribution={resolvedAttribution}
{...props}
/>
)
}
function MapLayerGroup({
name,
disabled,
...props
}: LayerGroupProps & MapLayerGroupOption & { ref?: Ref<LayerGroup> }) {
const context = useMapLayersContext()
useEffect(() => {
if (context) {
context.registerLayerGroup({
name,
disabled,
})
}
}, [context, name, disabled])
if (context && !context.activeLayerGroups.includes(name)) {
return null
}
return <LeafletLayerGroup {...props} />
}
function MapFeatureGroup({
name,
disabled,
...props
}: LayerGroupProps & MapLayerGroupOption & { ref?: Ref<FeatureGroup> }) {
const context = useMapLayersContext()
useEffect(() => {
if (context) {
context.registerLayerGroup({
name,
disabled,
})
}
}, [context, name, disabled])
if (context && !context.activeLayerGroups.includes(name)) {
return null
}
return <LeafletFeatureGroup {...props} />
}
function MapLayers({
defaultTileLayer,
defaultLayerGroups = [],
...props
}: Omit<React.ComponentProps<typeof MapLayersContext.Provider>, "value"> & {
defaultTileLayer?: string
defaultLayerGroups?: string[]
}) {
const [tileLayers, setTileLayers] = useState<MapTileLayerOption[]>([])
const [selectedTileLayer, setSelectedTileLayer] = useState<string>(
defaultTileLayer || ""
)
const [layerGroups, setLayerGroups] = useState<MapLayerGroupOption[]>([])
const [activeLayerGroups, setActiveLayerGroups] =
useState<string[]>(defaultLayerGroups)
function registerTileLayer(tileLayer: MapTileLayerOption) {
setTileLayers((prevTileLayers) => {
if (prevTileLayers.some((layer) => layer.name === tileLayer.name)) {
return prevTileLayers
}
return [...prevTileLayers, tileLayer]
})
}
function registerLayerGroup(layerGroup: MapLayerGroupOption) {
setLayerGroups((prevLayerGroups) => {
if (
prevLayerGroups.some((group) => group.name === layerGroup.name)
) {
return prevLayerGroups
}
return [...prevLayerGroups, layerGroup]
})
}
useEffect(() => {
// Error: Invalid defaultValue
if (
defaultTileLayer &&
tileLayers.length > 0 &&
!tileLayers.some((tileLayer) => tileLayer.name === defaultTileLayer)
) {
throw new Error(
`Invalid defaultTileLayer "${defaultTileLayer}" provided to MapLayers. It must match a MapTileLayer's name prop.`
)
}
// Set initial selected tile layer
if (tileLayers.length > 0 && !selectedTileLayer) {
const validDefaultValue =
defaultTileLayer &&
tileLayers.some((layer) => layer.name === defaultTileLayer)
? defaultTileLayer
: tileLayers[0].name
setSelectedTileLayer(validDefaultValue)
}
// Error: Invalid defaultActiveLayerGroups
if (
defaultLayerGroups.length > 0 &&
layerGroups.length > 0 &&
defaultLayerGroups.some(
(name) => !layerGroups.some((group) => group.name === name)
)
) {
throw new Error(
`Invalid defaultLayerGroups value provided to MapLayers. All names must match a MapLayerGroup's name prop.`
)
}
}, [
tileLayers,
defaultTileLayer,
selectedTileLayer,
layerGroups,
defaultLayerGroups,
])
return (
<MapLayersContext.Provider
value={{
registerTileLayer,
tileLayers,
selectedTileLayer,
setSelectedTileLayer,
registerLayerGroup,
layerGroups,
activeLayerGroups,
setActiveLayerGroups,
}}
{...props}
/>
)
}
function MapLayersControl({
tileLayersLabel = "Map Type",
layerGroupsLabel = "Layers",
className,
...props
}: React.ComponentProps<"button"> & {
tileLayersLabel?: string
layerGroupsLabel?: string
}) {
const layersContext = useMapLayersContext()
if (!layersContext) {
throw new Error("MapLayersControl must be used within MapLayers")
}
const {
tileLayers,
selectedTileLayer,
setSelectedTileLayer,
layerGroups,
activeLayerGroups,
setActiveLayerGroups,
} = layersContext
if (tileLayers.length === 0 && layerGroups.length === 0) {
return null
}
function handleLayerGroupToggle(name: string, checked: boolean) {
setActiveLayerGroups(
checked
? [...activeLayerGroups, name]
: activeLayerGroups.filter((groupName) => groupName !== name)
)
}
const showTileLayersDropdown = tileLayers.length > 1
const showLayerGroupsDropdown = layerGroups.length > 0
if (!showTileLayersDropdown && !showLayerGroupsDropdown) {
return null
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="secondary"
size="icon-sm"
aria-label="Select layers"
title="Select layers"
className={cn(
"absolute top-1 right-1 z-1000 border",
className
)}
{...props}>
<LayersIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-1000">
{showTileLayersDropdown && (
<>
<DropdownMenuLabel>{tileLayersLabel}</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={selectedTileLayer}
onValueChange={setSelectedTileLayer}>
{tileLayers.map((tileLayer) => (
<DropdownMenuRadioItem
key={tileLayer.name}
value={tileLayer.name}>
{tileLayer.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</>
)}
{showTileLayersDropdown && showLayerGroupsDropdown && (
<DropdownMenuSeparator />
)}
{showLayerGroupsDropdown && (
<>
<DropdownMenuLabel>
{layerGroupsLabel}
</DropdownMenuLabel>
{layerGroups.map((layerGroup) => (
<DropdownMenuCheckboxItem
key={layerGroup.name}
checked={activeLayerGroups.includes(
layerGroup.name
)}
disabled={layerGroup.disabled}
onCheckedChange={(checked) =>
handleLayerGroupToggle(
layerGroup.name,
checked
)
}>
{layerGroup.name}
</DropdownMenuCheckboxItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
function MapMarker({
icon = <MapPinIcon className="size-6" />,
iconAnchor = [12, 12],
bgPos,
popupAnchor,
tooltipAnchor,
...props
}: Omit<MarkerProps, "icon"> &
Pick<
DivIconOptions,
"iconAnchor" | "bgPos" | "popupAnchor" | "tooltipAnchor"
> & {
icon?: ReactNode
ref?: Ref<Marker>
}) {
const { L } = useLeaflet()
if (!L) return null
return (
<LeafletMarker
icon={L.divIcon({
html: renderToString(icon),
iconAnchor,
...(bgPos ? { bgPos } : {}),
...(popupAnchor ? { popupAnchor } : {}),
...(tooltipAnchor ? { tooltipAnchor } : {}),
})}
riseOnHover
{...props}
/>
)
}
function MapCircle({
className,
...props
}: CircleProps & { ref?: Ref<Circle> }) {
return (
<LeafletCircle
className={cn(
"fill-foreground stroke-foreground stroke-2",
className
)}
{...props}
/>
)
}
function MapCircleMarker({
className,
...props
}: CircleMarkerProps & { ref?: Ref<CircleMarker> }) {
return (
<LeafletCircleMarker
className={cn(
"fill-foreground stroke-foreground stroke-2",
className
)}
{...props}
/>
)
}
function MapPolyline({
className,
...props
}: PolylineProps & { ref?: Ref<Polyline> }) {
return (
<LeafletPolyline
className={cn(
"fill-foreground stroke-foreground stroke-2",
className
)}
{...props}
/>
)
}
function MapPolygon({
className,
...props
}: PolygonProps & { ref?: Ref<Polygon> }) {
return (
<LeafletPolygon
className={cn(
"fill-foreground stroke-foreground stroke-2",
className
)}
{...props}
/>
)
}
function MapRectangle({
className,
...props
}: RectangleProps & { ref?: Ref<Rectangle> }) {
return (
<LeafletRectangle
className={cn(
"fill-foreground stroke-foreground stroke-2",
className
)}
{...props}
/>
)
}
function MapPopup({
className,
...props
}: Omit<PopupProps, "content"> & { ref?: Ref<Popup> }) {
return (
<LeafletPopup
className={cn(
"bg-popover text-popover-foreground animate-in fade-out-0 fade-in-0 zoom-out-95 zoom-in-95 slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 font-sans shadow-md outline-hidden",
className
)}
{...props}
/>
)
}
function MapTooltip({
className,
children,
side = "top",
sideOffset = 15,
...props
}: Omit<TooltipProps, "offset"> & {
side?: "top" | "right" | "bottom" | "left"
sideOffset?: number
ref?: Ref<Tooltip>
}) {
const ARROW_POSITION_CLASSES = {
top: "bottom-0.5 left-1/2 -translate-x-1/2 translate-y-1/2",
bottom: "top-0.5 left-1/2 -translate-x-1/2 -translate-y-1/2",
left: "right-0.5 top-1/2 translate-x-1/2 -translate-y-1/2",
right: "left-0.5 top-1/2 -translate-x-1/2 -translate-y-1/2",
}
const DEFAULT_OFFSET = {
top: [0, -sideOffset] satisfies PointExpression,
bottom: [0, sideOffset] satisfies PointExpression,
left: [-sideOffset, 0] satisfies PointExpression,
right: [sideOffset, 0] satisfies PointExpression,
}
return (
<LeafletTooltip
className={cn(
"animate-in fade-in-0 zoom-in-95 fade-out-0 zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 w-fit text-xs text-balance transition-opacity",
className
)}
data-side={side}
direction={side}
offset={DEFAULT_OFFSET[side]}
opacity={1}
{...props}>
{children}
<div
className={cn(
"bg-foreground fill-foreground absolute z-50 size-2.5 rotate-45 rounded-[2px]",
ARROW_POSITION_CLASSES[side]
)}
/>
</LeafletTooltip>
)
}
function MapZoomControl({ className, ...props }: React.ComponentProps<"div">) {
const map = useMap()
const [zoomLevel, setZoomLevel] = useState(map.getZoom())
useMapEvents({
zoomend: () => {
setZoomLevel(map.getZoom())
},
})
return (
<ButtonGroup
orientation="vertical"
aria-label="Zoom controls"
className={cn("absolute top-1 left-1 z-1000 h-fit", className)}
{...props}>
<Button
type="button"
size="icon-sm"
variant="secondary"
aria-label="Zoom in"
title="Zoom in"
className="border"
disabled={zoomLevel >= map.getMaxZoom()}
onClick={() => map.zoomIn()}>
<PlusIcon />
</Button>
<Button
type="button"
size="icon-sm"
variant="secondary"
aria-label="Zoom out"
title="Zoom out"
className="border"
disabled={zoomLevel <= map.getMinZoom()}
onClick={() => map.zoomOut()}>
<MinusIcon />
</Button>
</ButtonGroup>
)
}
function MapLocatePulseIcon() {
return (
<div className="absolute -top-1 -right-1 flex size-3 rounded-full">
<div className="bg-primary absolute inline-flex size-full animate-ping rounded-full opacity-75" />
<div className="bg-primary relative inline-flex size-3 rounded-full" />
</div>
)
}
function MapLocateControl({
className,
watch = false,
onLocationFound,
onLocationError,
...props
}: React.ComponentProps<"button"> &
Pick<LocateOptions, "watch"> & {
onLocationFound?: (location: LocationEvent) => void
onLocationError?: (error: ErrorEvent) => void
}) {
const map = useMap()
const [isLocating, setIsLocating] = useDebounceLoadingState(200)
const [position, setPosition] = useState<LatLngExpression | null>(null)
function startLocating() {
setIsLocating(true)
map.locate({ setView: true, maxZoom: map.getMaxZoom(), watch })
map.on("locationfound", (location: LocationEvent) => {
setPosition(location.latlng)
setIsLocating(false)
onLocationFound?.(location)
})
map.on("locationerror", (error: ErrorEvent) => {
setPosition(null)
setIsLocating(false)
onLocationError?.(error)
})
}
function stopLocating() {
map.stopLocate()
map.off("locationfound")
map.off("locationerror")
setPosition(null)
setIsLocating(false)
}
useEffect(() => {
return () => stopLocating()
}, [])
return (
<>
<Button
type="button"
size="icon-sm"
variant={position ? "default" : "secondary"}
onClick={position ? stopLocating : startLocating}
disabled={isLocating}
title={
isLocating
? "Locating..."
: position
? "Stop tracking"
: "Track location"
}
aria-label={
isLocating
? "Locating..."
: position
? "Stop location tracking"
: "Start location tracking"
}
className={cn(
"absolute right-1 bottom-1 z-1000 border",
className
)}
{...props}>
{isLocating ? (
<LoaderCircleIcon className="animate-spin" />
) : (
<NavigationIcon />
)}
</Button>
{position && (
<MapMarker position={position} icon={<MapLocatePulseIcon />} />
)}
</>
)
}
type MapDrawShape = "marker" | "polyline" | "circle" | "rectangle" | "polygon"
type MapDrawAction = "edit" | "delete"
type MapDrawMode = MapDrawShape | MapDrawAction | null
interface MapDrawContextType {
readonly featureGroup: L.FeatureGroup | null
activeMode: MapDrawMode
setActiveMode: (mode: MapDrawMode) => void
readonly editControlRef: React.RefObject<EditToolbar.Edit | null>
readonly deleteControlRef: React.RefObject<EditToolbar.Delete | null>
}
const MapDrawContext = createContext<MapDrawContextType | null>(null)
function useMapDrawContext() {
return useContext(MapDrawContext)
}
function MapDrawControl({
className,
onLayersChange,
...props
}: React.ComponentProps<"div"> & {
onLayersChange?: (layers: L.FeatureGroup) => void
}) {
const { L, LeafletDraw } = useLeaflet()
const map = useMap()
const featureGroupRef = useRef<L.FeatureGroup | null>(null)
const editControlRef = useRef<EditToolbar.Edit | null>(null)
const deleteControlRef = useRef<EditToolbar.Delete | null>(null)
const [activeMode, setActiveMode] = useState<MapDrawMode>(null)
function handleDrawCreated(event: DrawEvents.Created) {
if (!featureGroupRef.current) return
const { layer } = event
featureGroupRef.current.addLayer(layer)
onLayersChange?.(featureGroupRef.current)
setActiveMode(null)
}
function handleDrawEditedOrDeleted() {
if (!featureGroupRef.current) return
onLayersChange?.(featureGroupRef.current)
setActiveMode(null)
}
useEffect(() => {
if (!L || !LeafletDraw) return
map.on(
L.Draw.Event.CREATED,
handleDrawCreated as L.LeafletEventHandlerFn
)
map.on(L.Draw.Event.EDITED, handleDrawEditedOrDeleted)
map.on(L.Draw.Event.DELETED, handleDrawEditedOrDeleted)
return () => {
map.off(
L.Draw.Event.CREATED,
handleDrawCreated as L.LeafletEventHandlerFn
)
map.off(L.Draw.Event.EDITED, handleDrawEditedOrDeleted)
map.off(L.Draw.Event.DELETED, handleDrawEditedOrDeleted)
}
}, [L, LeafletDraw, map, onLayersChange])
return (
<MapDrawContext.Provider
value={{
featureGroup: featureGroupRef.current,
activeMode,
setActiveMode,
editControlRef,
deleteControlRef,
}}>
<LeafletFeatureGroup ref={featureGroupRef} />
<ButtonGroup
orientation="vertical"
className={cn("absolute bottom-1 left-1 z-1000", className)}
{...props}
/>
</MapDrawContext.Provider>
)
}
function MapDrawShapeButton<T extends Draw.Feature>({
drawMode,
createDrawTool,
className,
...props
}: React.ComponentProps<"button"> & {
drawMode: MapDrawShape
createDrawTool: (L: typeof import("leaflet"), map: DrawMap) => T
}) {
const drawContext = useMapDrawContext()
if (!drawContext) {
throw new Error("MapDrawShapeButton must be used within MapDrawControl")
}
const { L } = useLeaflet()
const map = useMap()
const controlRef = useRef<T | null>(null)
const { activeMode, setActiveMode } = drawContext
const isActive = activeMode === drawMode
useEffect(() => {
if (!L || !isActive) {
controlRef.current?.disable()
controlRef.current = null
return
}
const control = createDrawTool(L, map as DrawMap)
control.enable()
controlRef.current = control
return () => {
control.disable()
controlRef.current = null
}
}, [L, map, isActive, createDrawTool])
function handleClick() {
setActiveMode(isActive ? null : drawMode)
}
return (
<Button
type="button"
size="icon-sm"
aria-label={`Draw ${drawMode}`}
title={`Draw ${drawMode}`}
className={cn("border", className)}
variant={isActive ? "default" : "secondary"}
disabled={activeMode === "edit" || activeMode === "delete"}
onClick={handleClick}
{...props}
/>
)
}
function MapDrawMarker({ ...props }: DrawOptions.MarkerOptions) {
return (
<MapDrawShapeButton
drawMode="marker"
createDrawTool={(L, map) =>
new L.Draw.Marker(map, {
icon: L.divIcon({
className: "", // For fixing the moving bug when going in and out the edit mode
iconAnchor: [12, 12],
html: renderToString(<MapPinIcon className="size-6" />),
}),
...props,
})
}>
<MapPinIcon />
</MapDrawShapeButton>
)
}
function MapDrawPolyline({
showLength = false,
drawError = {
color: "var(--color-destructive)",
},
shapeOptions = {
color: "var(--color-primary)",
opacity: 1,
weight: 2,
},
...props
}: DrawOptions.PolylineOptions) {
const mapDrawHandleIcon = useMapDrawHandleIcon()
return (
<MapDrawShapeButton
drawMode="polyline"
createDrawTool={(L, map) =>
new L.Draw.Polyline(map, {
...(mapDrawHandleIcon
? {
icon: mapDrawHandleIcon,
touchIcon: mapDrawHandleIcon,
}
: {}),
showLength,
drawError,
shapeOptions,
...props,
})
}>
<WaypointsIcon />
</MapDrawShapeButton>
)
}
function MapDrawCircle({
showRadius = false,
shapeOptions = {
color: "var(--color-primary)",
opacity: 1,
weight: 2,
},
...props
}: DrawOptions.CircleOptions) {
return (
<MapDrawShapeButton
drawMode="circle"
createDrawTool={(L, map) =>
new L.Draw.Circle(map, {
showRadius,
shapeOptions,
...props,
})
}>
<CircleIcon />
</MapDrawShapeButton>
)
}
function MapDrawRectangle({
showArea = false,
shapeOptions = {
color: "var(--color-primary)",
opacity: 1,
weight: 2,
},
...props
}: DrawOptions.RectangleOptions) {
return (
<MapDrawShapeButton
drawMode="rectangle"
createDrawTool={(L, map) =>
new L.Draw.Rectangle(map, {
showArea,
shapeOptions,
...props,
})
}>
<SquareIcon />
</MapDrawShapeButton>
)
}
function MapDrawPolygon({
drawError = {
color: "var(--color-destructive)",
},
shapeOptions = {
color: "var(--color-primary)",
opacity: 1,
weight: 2,
},
...props
}: DrawOptions.PolygonOptions) {
const mapDrawHandleIcon = useMapDrawHandleIcon()
return (
<MapDrawShapeButton
drawMode="polygon"
createDrawTool={(L, map) =>
new L.Draw.Polygon(map, {
...(mapDrawHandleIcon
? {
icon: mapDrawHandleIcon,
touchIcon: mapDrawHandleIcon,
}
: {}),
drawError,
shapeOptions,
...props,
})
}>
<PentagonIcon />
</MapDrawShapeButton>
)
}
function MapDrawActionButton<T extends EditToolbar.Edit | EditToolbar.Delete>({
drawAction,
createDrawTool,
controlRef,
className,
...props
}: React.ComponentProps<"button"> & {
drawAction: MapDrawAction
createDrawTool: (
L: typeof import("leaflet"),
map: DrawMap,
featureGroup: L.FeatureGroup
) => T
controlRef: React.RefObject<T | null>
}) {
const drawContext = useMapDrawContext()
if (!drawContext)
throw new Error(
"MapDrawActionButton must be used within MapDrawControl"
)
const { L } = useLeaflet()
const map = useMap()
const { featureGroup, activeMode, setActiveMode } = drawContext
const isActive = activeMode === drawAction
const hasFeatures = featureGroup?.getLayers().length ?? 0 > 0
useEffect(() => {
if (!L || !featureGroup || !isActive) {
controlRef.current?.disable?.()
controlRef.current = null
return
}
const control = createDrawTool(L, map as DrawMap, featureGroup)
control.enable?.()
controlRef.current = control
return () => {
control.disable?.()
controlRef.current = null
}
}, [L, map, isActive, featureGroup, createDrawTool])
function handleClick() {
controlRef.current?.save()
setActiveMode(isActive ? null : drawAction)
}
return (
<Button
type="button"
size="icon-sm"
aria-label={`${drawAction === "edit" ? "Edit" : "Remove"} shapes`}
title={`${drawAction === "edit" ? "Edit" : "Remove"} shapes`}
variant={isActive ? "default" : "secondary"}
disabled={!hasFeatures}
onClick={handleClick}
className={cn("border", className)}
{...props}
/>
)
}
function MapDrawEdit({
selectedPathOptions = {
color: "var(--color-primary)",
fillColor: "var(--color-primary)",
weight: 2,
},
...props
}: Omit<EditToolbar.EditHandlerOptions, "featureGroup">) {
const { L } = useLeaflet()
const mapDrawHandleIcon = useMapDrawHandleIcon()
const drawContext = useMapDrawContext()
if (!drawContext) {
throw new Error("MapDrawEdit must be used within MapDrawControl")
}
useEffect(() => {
if (!L || !mapDrawHandleIcon) return
L.Edit.PolyVerticesEdit.mergeOptions({
icon: mapDrawHandleIcon,
touchIcon: mapDrawHandleIcon,
drawError: {
color: "var(--color-destructive)",
},
})
L.Edit.SimpleShape.mergeOptions({
moveIcon: mapDrawHandleIcon,
resizeIcon: mapDrawHandleIcon,
touchMoveIcon: mapDrawHandleIcon,
touchResizeIcon: mapDrawHandleIcon,
})
L.drawLocal.edit.handlers.edit.tooltip = {
text: "Drag handles or markers to edit.",
subtext: "",
}
L.drawLocal.edit.handlers.remove.tooltip = {
text: "Click on a shape to remove.",
}
}, [mapDrawHandleIcon])
return (
<MapDrawActionButton
drawAction="edit"
controlRef={drawContext.editControlRef}
createDrawTool={(L, map, featureGroup) =>
new L.EditToolbar.Edit(map, {
featureGroup,
selectedPathOptions,
...props,
})
}>
<PenLineIcon />
</MapDrawActionButton>
)
}
function MapDrawDelete() {
const drawContext = useMapDrawContext()
if (!drawContext) {
throw new Error("MapDrawDelete must be used within MapDrawControl")
}
return (
<MapDrawActionButton
drawAction="delete"
controlRef={drawContext.deleteControlRef}
createDrawTool={(L, map, featureGroup) =>
new L.EditToolbar.Delete(map, { featureGroup })
}>
<Trash2Icon />
</MapDrawActionButton>
)
}
function MapDrawUndo({ className, ...props }: React.ComponentProps<"button">) {
const drawContext = useMapDrawContext()
if (!drawContext)
throw new Error("MapDrawUndo must be used within MapDrawControl")
const { activeMode, setActiveMode, editControlRef, deleteControlRef } =
drawContext
const isInEditMode = activeMode === "edit"
const isInDeleteMode = activeMode === "delete"
const isActive = isInEditMode || isInDeleteMode
function handleUndo() {
if (isInEditMode) {
editControlRef.current?.revertLayers()
} else if (isInDeleteMode) {
deleteControlRef.current?.revertLayers()
}
setActiveMode(null)
}
return (
<Button
type="button"
size="icon-sm"
variant="secondary"
aria-label={`Undo ${activeMode}`}
title={`Undo ${activeMode}`}
onClick={handleUndo}
disabled={!isActive}
className={cn("border", className)}
{...props}>
<Undo2Icon />
</Button>
)
}
function useMapDrawHandleIcon() {
const { L } = useLeaflet()
if (!L) return null
return L.divIcon({
iconAnchor: [8, 8],
html: renderToString(
<CircleIcon className="fill-primary stroke-primary size-4 transition-transform hover:scale-110" />
),
})
}
function useLeaflet() {
const [L, setL] = useState<typeof import("leaflet") | null>(null)
const [LeafletDraw, setLeafletDraw] = useState<
typeof import("leaflet-draw") | null
>(null)
useEffect(() => {
if (L && LeafletDraw) return
if (typeof window !== "undefined") {
if (!L) {
setL(require("leaflet"))
}
if (!LeafletDraw) {
setLeafletDraw(require("leaflet-draw"))
}
}
}, [L, LeafletDraw])
return { L, LeafletDraw }
}
function useDebounceLoadingState(delay = 200) {
const [isLoading, setIsLoading] = useState(false)
const [showLoading, setShowLoading] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (isLoading) {
timeoutRef.current = setTimeout(() => {
setShowLoading(true)
}, delay)
} else {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setShowLoading(false)
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [isLoading, delay])
return [showLoading, setIsLoading] as const
}
export {
Map,
MapCircle,
MapCircleMarker,
MapDrawCircle,
MapDrawControl,
MapDrawDelete,
MapDrawEdit,
MapDrawMarker,
MapDrawPolygon,
MapDrawPolyline,
MapDrawRectangle,
MapDrawUndo,
MapFeatureGroup,
MapLayerGroup,
MapLayers,
MapLayersControl,
MapLocateControl,
MapMarker,
MapPolygon,
MapPolyline,
MapPopup,
MapRectangle,
MapTileLayer,
MapTooltip,
MapZoomControl,
useLeaflet,
}