Light Canvas
leaflet-mapuiEsri Light Gray Canvas raster tiles — a clean, key-free editorial light palette for SaaS reporting.
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/leaflet-map.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/leaflet-map.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/leaflet-map.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/leaflet-map.jsonnpx shadcn-vue@latest add @uipkge/leaflet-mapInstalls to:app/components/ui/leaflet-map/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variantNamed raster basemap preset ('streets' | 'outdoors' | 'satellite' | 'satellite-streets' | 'light' | 'dark' | 'navigation-day' | 'navigation-night' | 'standard' | 'muted' | 'default'). | 'default''muted''streets''outdoors''light''dark''satellite''satellite-streets''navigation-day''navigation-night''standard' | 'default' | optional |
sizeHeight preset. Omit to size via `class` (blocks typically pass `size-full`). | 'default''sm''lg''xl''full' | — | optional |
tileUrlCustom raster tile URL template — overrides `variant`. | string | undefined | optional |
tileAttributionAttribution HTML for a custom `tile-url`. Defaults to the OpenStreetMap credit. | string | undefined | optional |
tileSubdomainsTile subdomains for a custom `tile-url` ('abcd' or ['a','b']). | string | string[] | undefined | optional |
centerInitial [lng, lat] — Mapbox order, matching the `map` component. | [number, number] | () => [0 | optional |
zoom | number | — | 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 | — | optional |
navigationPositionPlacement of the zoom control ('top-left' | 'top-right' | 'bottom-left' | 'bottom-right'). | LeafletPosition | — | optional |
fullscreenShow the HTML5 fullscreen toggle button. | boolean | — | optional |
fullscreenPositionPlacement of the fullscreen button. Defaults to 'top-right'. | LeafletPosition | — | optional |
attributionShow tile credits behind a ⓘ button (reveals on hover/tap). Keep on — OSM/Esri tiles require attribution. | boolean | — | optional |
scrollWheelZoomWheel zoom. Set false for maps embedded in scrollable pages. | boolean | — | optional |
mutedDesaturate the tile pane to a quiet canvas (markers stay coloured). | boolean | — | optional |
class | string | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
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
}npm dependencies
Files installed (14)
app/components/ui/leaflet-map/LeafletMap.vue16.8 kB
<script setup lang="ts"> /** * LeafletMap — a thin, theme-aware Leaflet wrapper that renders free raster * tiles (OpenStreetMap, OpenTopoMap, Esri). No API key required. * * Drop `<LeafletMarker>` / `<LeafletPopup>` / `<LeafletPolyline>` / * `<LeafletPolygon>` / `<LeafletCircle>` / `<LeafletCircleMarker>` / * `<LeafletGeoJson>` / `<LeafletTileLayer>` into the default slot to build any * map — the same composition model as the Mapbox `map` component. * * Basemap `variant` presets (all key-free tile providers): * - `default`: theme-aware Esri light/dark canvas * - `streets` / `standard`: OpenStreetMap Standard * - `light` / `dark`: Esri Light/Dark Gray Canvas * - `muted`: theme-aware Esri canvas + desaturated tile pane * - `outdoors`: OpenTopoMap topographic contours * - `satellite`: Esri World Imagery * - `satellite-streets`: Esri imagery + Esri reference labels overlay * - `navigation-day`: Esri World Street Map * - `navigation-night`: Esri Dark Gray Canvas * * Not supported (Leaflet has no vector/GL renderer — these Mapbox props have * no counterpart): pitch, bearing, 3D buildings/terrain, globe projection. * Use a `variant` preset or `tile-url` for custom raster tiles instead. */ import { computed, onMounted, onUnmounted, provide, ref, shallowRef, watch } from 'vue' import type * as L from 'leaflet' import { cn } from '@/lib/utils' import { defined, fixDefaultLeafletIcon, loadLeaflet, toLatLng, toLatLngBounds, LEAFLET_MAP_KEY, type LeafletPosition, } from './leaflet-context' 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' const props = withDefaults( defineProps<{ /** 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 `class` (blocks typically pass `size-full`). */ size?: LeafletMapVariants['size'] /** Custom raster tile URL template — overrides `variant`. */ tileUrl?: string /** Attribution HTML for a custom `tile-url`. Defaults to the OpenStreetMap credit. */ tileAttribution?: string /** Tile subdomains for a custom `tile-url` ('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 class?: string }>(), { variant: 'default', tileUrl: undefined, tileAttribution: undefined, tileSubdomains: undefined, center: () => [0, 20], zoom: 2, minZoom: undefined, maxZoom: undefined, navigation: true, navigationPosition: 'bottom-right', fullscreen: false, fullscreenPosition: 'top-right', attribution: true, scrollWheelZoom: true, muted: false, }, ) const emit = defineEmits<{ (e: 'created', map: L.Map): void }>() const htmlDark = ref(false) const isMuted = computed(() => props.muted || props.variant === 'muted') const resolvedTiles = computed<LeafletTilePreset>(() => { if (props.tileUrl) { return { url: props.tileUrl, attribution: props.tileAttribution ?? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', subdomains: props.tileSubdomains, maxZoom: props.maxZoom, } } if ( props.variant && props.variant !== 'default' && props.variant !== 'muted' && LEAFLET_TILES[props.variant as keyof typeof LEAFLET_TILES] ) { return LEAFLET_TILES[props.variant as keyof typeof LEAFLET_TILES] } return htmlDark.value ? LEAFLET_THEME_TILES.dark : LEAFLET_THEME_TILES.light }) // Client-only: Leaflet needs the DOM, so SSR renders just the bg-muted shell. const mounted = ref(false) const inView = ref(false) const containerRef = ref<HTMLDivElement | null>(null) const mapEl = ref<HTMLDivElement | null>(null) const map = shallowRef<L.Map | null>(null) let Lmod: typeof import('leaflet') | null = null let baseLayer: L.TileLayer | null = null let overlayLayer: L.TileLayer | null = null let resizeObserver: ResizeObserver | null = null let intersectionObserver: IntersectionObserver | null = null let themeObserver: MutationObserver | null = null let onFullscreenChange: (() => void) | null = null // Published to LeafletMarker / LeafletPopup / … children. provide(LEAFLET_MAP_KEY, map) function applyTiles(tiles: LeafletTilePreset) { const m = map.value const L = Lmod if (!m || !L) return baseLayer?.remove() baseLayer = null overlayLayer?.remove() overlayLayer = null baseLayer = L.tileLayer(tiles.url, { attribution: tiles.attribution, ...(tiles.subdomains ? { subdomains: tiles.subdomains } : {}), maxZoom: tiles.maxZoom ?? 19, }) baseLayer.addTo(m) if (tiles.overlayUrl) { overlayLayer = L.tileLayer(tiles.overlayUrl, { maxZoom: tiles.maxZoom ?? 19 }) overlayLayer.addTo(m) } } // Collect attribution strings from every layer (base tiles, overlays, custom // LeafletTileLayers) — deduped, rendered by the ⓘ popover. const attributions = ref<string[]>([]) const showAttribution = ref(false) function collectAttributions() { const m = map.value 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) }) attributions.value = [...seen] } function createMap() { const el = mapEl.value const L = Lmod if (!el || !L || map.value) return fixDefaultLeafletIcon(L) const tiles = resolvedTiles.value const m = L.map( el, defined({ center: toLatLng(props.center ?? [0, 20]), zoom: props.zoom, minZoom: props.minZoom, maxZoom: props.maxZoom ?? tiles.maxZoom, zoomControl: false, attributionControl: false, scrollWheelZoom: props.scrollWheelZoom, }), ) map.value = m applyTiles(tiles) m.on('zoomend', syncZoomBounds) syncZoomBounds() m.on('layeradd layerremove', collectAttributions) collectAttributions() emit('created', m) } onMounted(() => { const root = document.documentElement const syncTheme = () => { htmlDark.value = root.classList.contains('dark') } syncTheme() themeObserver = new MutationObserver(syncTheme) themeObserver.observe(root, { attributes: true, attributeFilter: ['class'] }) mounted.value = true const el = containerRef.value if (!el || typeof IntersectionObserver === 'undefined') { inView.value = true } else { intersectionObserver = new IntersectionObserver( ([entry]) => { inView.value = entry.isIntersecting }, { rootMargin: '160px', threshold: 0.01 }, ) intersectionObserver.observe(el) } if (typeof ResizeObserver !== 'undefined' && el) { resizeObserver = new ResizeObserver(() => { map.value?.invalidateSize() }) resizeObserver.observe(el) } onFullscreenChange = () => { isFullscreen.value = Boolean(document.fullscreenElement) } document.addEventListener('fullscreenchange', onFullscreenChange) }) onUnmounted(() => { resizeObserver?.disconnect() intersectionObserver?.disconnect() themeObserver?.disconnect() if (onFullscreenChange) document.removeEventListener('fullscreenchange', onFullscreenChange) map.value?.remove() map.value = null }) // Leaflet module loads lazily once the container is both mounted and in view. watch([mounted, inView], async ([isMounted, visible]) => { if (!isMounted || !visible || map.value) return Lmod = await loadLeaflet() createMap() }) // Retile on variant / theme / custom-tile changes. watch(resolvedTiles, (tiles) => { if (map.value) applyTiles(tiles) }) // Watch the coordinates, not the array: a parent re-render that passes a // fresh `[lng, lat]` literal must not snap the user's pan/zoom back. watch( [() => props.center?.[0], () => props.center?.[1], () => props.zoom], () => { const m = map.value if (!m || !props.center) return m.setView(toLatLng(props.center), props.zoom) }, ) watch( () => props.attribution, () => { if (!props.attribution) showAttribution.value = false }, ) watch( () => props.scrollWheelZoom, (enabled) => { if (!map.value) return if (enabled) map.value.scrollWheelZoom.enable() else map.value.scrollWheelZoom.disable() }, ) const isFullscreen = ref(false) function toggleFullscreen() { const el = containerRef.value if (!el) return if (document.fullscreenElement) document.exitFullscreen() else el.requestFullscreen?.() } // 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 cornerOrder: LeafletPosition[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'] const cornerClasses = computed<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': props.attribution && attributions.value.length ? 'bottom-9 left-3' : 'bottom-3 left-3', 'bottom-right': 'bottom-3 right-3', })) const navPosition = computed(() => props.navigationPosition ?? 'bottom-right') const fsPosition = computed(() => props.fullscreenPosition ?? 'top-right') const canZoomIn = ref(true) const canZoomOut = ref(true) function syncZoomBounds() { const m = map.value if (!m) return canZoomIn.value = m.getZoom() < m.getMaxZoom() canZoomOut.value = m.getZoom() > m.getMinZoom() } /** Mapbox-style camera shim — accepts { center: [lng, lat], zoom, duration(ms) }. */ function flyTo(options: { center?: [number, number]; zoom?: number; duration?: number } = {}) { const m = map.value if (!m) return const target = options.center ? toLatLng(options.center) : m.getCenter() m.flyTo(target, options.zoom ?? m.getZoom(), { duration: (options.duration ?? 800) / 1000, }) } function setView(options: { center?: [number, number]; zoom?: number } = {}) { const m = map.value if (!m) return m.setView(options.center ? toLatLng(options.center) : m.getCenter(), options.zoom ?? m.getZoom()) } function jumpTo(options: { center?: [number, number]; zoom?: number } = {}) { const m = map.value if (!m) return m.setView(options.center ? toLatLng(options.center) : m.getCenter(), options.zoom ?? m.getZoom(), { animate: false, }) } // <script setup> components are closed by default, so a template ref on // <LeafletMap> hands back this object. It mirrors the `map` component's MapRef // surface: camera helpers plus `map` / `getMap()` for the raw L.Map. defineExpose({ /** The underlying Leaflet Map, or null until it is created. */ get map() { return map.value }, getMap: () => map.value, flyTo, setView, jumpTo, /** [[west,south],[east,north]] in [lng, lat], or any Leaflet bounds expression. */ fitBounds: (bounds: [[number, number], [number, number]] | L.LatLngBoundsExpression, options?: L.FitBoundsOptions) => map.value?.fitBounds(toLatLngBounds(bounds as [[number, number], [number, number]]), options), panTo: (center: [number, number]) => map.value?.panTo(toLatLng(center)), zoomIn: () => map.value?.zoomIn(), zoomOut: () => map.value?.zoomOut(), resize: () => map.value?.invalidateSize(), }) </script> <template> <div ref="containerRef" data-uipkge data-slot="leaflet-map" :data-variant="variant" :data-muted="isMuted" :class="cn(leafletMapVariants({ variant, ...(size ? { size } : {}) }), props.class)" > <div v-show="inView" ref="mapEl" class="size-full" /> <template v-for="corner in cornerOrder" :key="corner"> <div v-if="map && ((navigation && navPosition === corner) || (fullscreen && fsPosition === corner))" class="absolute z-[1000] flex flex-col gap-2.5" :class="cornerClasses[corner]" > <div v-if="navigation && navPosition === corner" class="border-border bg-card divide-border flex flex-col divide-y overflow-hidden rounded-lg border shadow-sm" > <button type="button" class="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" @click="map?.zoomIn()" > <svg class="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" class="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" @click="map?.zoomOut()" > <svg class="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> <button v-if="fullscreen && fsPosition === corner" type="button" class="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" @click="toggleFullscreen" > <svg class="size-full" viewBox="0 0 29 29" fill="currentColor" aria-hidden="true"> <path v-if="isFullscreen" 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 v-else 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> </template> <!-- Tile credits behind a Mapbox-style ⓘ button: hover reveals on desktop, tap toggles on touch. Keep visible — OSM/Esri tiles require credit. --> <div v-if="map && attribution && attributions.length" class="group absolute bottom-3 left-3 z-[1000] flex flex-col items-start gap-1.5" > <div class="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" :class=" showAttribution ? 'visible opacity-100' : 'invisible opacity-0 group-hover:visible group-hover:opacity-100' " role="note" v-html="attributions.join(' | ')" /> <button type="button" class="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" @click="showAttribution = !showAttribution" > <svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" > <circle cx="12" cy="12" r="10" /> <path d="M12 16v-4M12 8h.01" /> </svg> </button> </div> <slot /> </div> </template>app/components/ui/leaflet-map/LeafletMarker.vue2.3 kB
<script setup lang="ts"> import { Comment, Text, computed, ref, useSlots, watch } from 'vue' import type * as L from 'leaflet' import { defined, toLatLng, useLeafletLayer } from './leaflet-context' type MarkerAnchor = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' const props = withDefaults( defineProps<{ /** [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 }>(), { anchor: 'center' }, ) const emit = defineEmits<{ (e: 'click', ev: L.LeafletMouseEvent): void (e: 'ready', marker: L.Marker): void }>() const slots = useSlots() const el = ref<HTMLElement | null>(null) // LeafletPopup / LeafletTooltip children bind to the marker; every other slot // node becomes custom div-icon content (a real element — listeners survive). const hasIconContent = computed(() => (slots.default?.() ?? []).some((n) => { if (n.type === Comment || n.type === Text) return false const name = (n.type as any)?.__name ?? (n.type as any)?.name return name !== 'LeafletPopup' && name !== 'LeafletTooltip' }), ) const layer = useLeafletLayer((map, L) => { const options: L.MarkerOptions = defined({ interactive: true, draggable: props.draggable, opacity: props.opacity, zIndexOffset: props.zIndexOffset, title: props.title, alt: props.alt, }) if (hasIconContent.value && el.value) { options.icon = L.divIcon({ className: 'uipkge-leaflet-div-icon', html: el.value }) } const marker = L.marker(toLatLng(props.lngLat), options) marker.on('click', (ev) => emit('click', ev)) return marker }) watch( layer, (m) => { if (m) emit('ready', m) }, { immediate: true }, ) watch( () => props.lngLat, (v) => { if (v) layer.value?.setLatLng(toLatLng(v)) }, ) watch( () => props.opacity, (v) => { if (v !== undefined) layer.value?.setOpacity(v) }, ) watch( () => props.zIndexOffset, (v) => { if (v !== undefined) layer.value?.setZIndexOffset(v) }, ) </script> <template> <div ref="el" class="uipkge-leaflet-anchor" :data-anchor="anchor"><slot /></div> </template>app/components/ui/leaflet-map/LeafletPopup.vue1.6 kB
<script setup lang="ts"> import { onUnmounted, ref, watch } from 'vue' import type * as L from 'leaflet' import { defined, loadLeaflet, toLatLng, useLeafletMap, useParentLeafletLayer } from './leaflet-context' defineOptions({ name: 'LeafletPopup' }) const props = withDefaults( defineProps<{ /** [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 }>(), { minWidth: 200 }, ) const map = useLeafletMap() const parentLayer = useParentLeafletLayer() const el = ref<HTMLElement | null>(null) let popup: L.Popup | null = null let boundTo: L.Layer | null = null watch( [map, () => parentLayer?.value], async ([m, layer]) => { if (!m || !el.value) return const L = await loadLeaflet() if (layer) { boundTo = layer layer.bindPopup(el.value, defined({ ...props, offset: props.offset })) return } if (props.lngLat && !popup) { popup = L.popup(defined({ ...props, offset: props.offset })) .setLatLng(toLatLng(props.lngLat)) .setContent(el.value) popup.openOn(m) } }, { immediate: true }, ) onUnmounted(() => { try { boundTo?.unbindPopup() popup?.remove() } catch { /* map already destroyed */ } }) </script> <template> <div ref="el" class="uipkge-leaflet-popup-src"><slot /></div> </template>app/components/ui/leaflet-map/LeafletTooltip.vue1.5 kB
<script setup lang="ts"> import { onUnmounted, ref, watch } from 'vue' import type * as L from 'leaflet' import { defined, loadLeaflet, toLatLng, useLeafletMap, useParentLeafletLayer } from './leaflet-context' defineOptions({ name: 'LeafletTooltip' }) const props = withDefaults( defineProps<{ /** [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 }>(), {}, ) const map = useLeafletMap() const parentLayer = useParentLeafletLayer() const el = ref<HTMLElement | null>(null) let tooltip: L.Tooltip | null = null let boundTo: L.Layer | null = null watch( [map, () => parentLayer?.value], async ([m, layer]) => { if (!m || !el.value) return const L = await loadLeaflet() if (layer) { boundTo = layer layer.bindTooltip(el.value, defined({ ...props, offset: props.offset })) return } if (props.lngLat && !tooltip) { tooltip = L.tooltip(defined({ ...props, offset: props.offset })) .setLatLng(toLatLng(props.lngLat)) .setContent(el.value) tooltip.addTo(m) } }, { immediate: true }, ) onUnmounted(() => { try { boundTo?.unbindTooltip() tooltip?.remove() } catch { /* map already destroyed */ } }) </script> <template> <div ref="el" class="uipkge-leaflet-tooltip-src"><slot /></div> </template>app/components/ui/leaflet-map/LeafletPolyline.vue1.6 kB
<script setup lang="ts"> import { watch } from 'vue' import type * as L from 'leaflet' import { defined, toLatLngs, useLeafletLayer } from './leaflet-context' defineOptions({ name: 'LeafletPolyline' }) const props = defineProps<{ /** Path points as [lng, lat][] — or [lng, lat][][] for multi-part lines. */ lngLatPath: [number, number][] | [number, number][][] color?: string weight?: number opacity?: number lineCap?: 'butt' | 'round' | 'square' lineJoin?: 'miter' | 'round' | 'bevel' dashArray?: string | number[] dashOffset?: string smoothFactor?: number noClip?: boolean className?: string }>() const emit = defineEmits<{ (e: 'click', ev: L.LeafletMouseEvent): void }>() const pathOptions = () => defined({ color: props.color, weight: props.weight, opacity: props.opacity, lineCap: props.lineCap, lineJoin: props.lineJoin, dashArray: props.dashArray, dashOffset: props.dashOffset, smoothFactor: props.smoothFactor, noClip: props.noClip, className: props.className, interactive: true, } as L.PolylineOptions) const layer = useLeafletLayer((map, L) => { const line = L.polyline(toLatLngs(props.lngLatPath) as L.LatLngExpression[], pathOptions()) line.on('click', (ev) => emit('click', ev)) return line }) watch( () => props.lngLatPath, (v) => layer.value?.setLatLngs(toLatLngs(v) as L.LatLngExpression[]), { deep: true }, ) watch( () => [props.color, props.weight, props.opacity, props.dashArray, props.dashOffset, props.lineCap, props.lineJoin], () => layer.value?.setStyle(pathOptions()), ) </script> <template> <div class="hidden"><slot /></div> </template>app/components/ui/leaflet-map/LeafletPolygon.vue1.7 kB
<script setup lang="ts"> import { watch } from 'vue' import type * as L from 'leaflet' import { defined, toLatLngs, useLeafletLayer } from './leaflet-context' defineOptions({ name: 'LeafletPolygon' }) const props = defineProps<{ /** Ring points as [lng, lat][] — or [lng, lat][][] for holes/multi-polygons. */ lngLatPath: [number, number][] | [number, number][][] 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 }>() const emit = defineEmits<{ (e: 'click', ev: L.LeafletMouseEvent): void }>() const pathOptions = () => defined({ color: props.color, weight: props.weight, opacity: props.opacity, lineCap: props.lineCap, lineJoin: props.lineJoin, dashArray: props.dashArray, dashOffset: props.dashOffset, fill: props.fill, fillColor: props.fillColor, fillOpacity: props.fillOpacity, className: props.className, interactive: true, } as L.PolylineOptions) const layer = useLeafletLayer((map, L) => { const polygon = L.polygon(toLatLngs(props.lngLatPath) as L.LatLngExpression[], pathOptions()) polygon.on('click', (ev) => emit('click', ev)) return polygon }) watch( () => props.lngLatPath, (v) => layer.value?.setLatLngs(toLatLngs(v) as L.LatLngExpression[]), { deep: true }, ) watch( () => [props.color, props.weight, props.opacity, props.fill, props.fillColor, props.fillOpacity, props.dashArray], () => layer.value?.setStyle(pathOptions()), ) </script> <template> <div class="hidden"><slot /></div> </template>app/components/ui/leaflet-map/LeafletCircle.vue1.5 kB
<script setup lang="ts"> import { watch } from 'vue' import type * as L from 'leaflet' import { defined, toLatLng, useLeafletLayer } from './leaflet-context' defineOptions({ name: 'LeafletCircle' }) const props = defineProps<{ /** [lng, lat] — Mapbox order. */ center: [number, number] /** Radius in meters. */ radius?: number color?: string weight?: number opacity?: number dashArray?: string | number[] fill?: boolean fillColor?: string fillOpacity?: number className?: string }>() const emit = defineEmits<{ (e: 'click', ev: L.LeafletMouseEvent): void }>() const pathOptions = () => defined({ color: props.color, weight: props.weight, opacity: props.opacity, dashArray: props.dashArray, fill: props.fill, fillColor: props.fillColor, fillOpacity: props.fillOpacity, className: props.className, interactive: true, } as L.CircleMarkerOptions) const layer = useLeafletLayer((map, L) => { const circle = L.circle(toLatLng(props.center), { ...pathOptions(), radius: props.radius }) circle.on('click', (ev) => emit('click', ev)) return circle }) watch( () => props.center, (v) => { if (v) layer.value?.setLatLng(toLatLng(v)) }, ) watch( () => props.radius, (v) => { if (v !== undefined) layer.value?.setRadius(v) }, ) watch( () => [props.color, props.weight, props.opacity, props.fill, props.fillColor, props.fillOpacity, props.dashArray], () => layer.value?.setStyle(pathOptions()), ) </script> <template> <div class="hidden"><slot /></div> </template>app/components/ui/leaflet-map/LeafletCircleMarker.vue1.5 kB
<script setup lang="ts"> import { watch } from 'vue' import type * as L from 'leaflet' import { defined, toLatLng, useLeafletLayer } from './leaflet-context' defineOptions({ name: 'LeafletCircleMarker' }) const props = defineProps<{ /** [lng, lat] — Mapbox order. */ center: [number, number] /** Radius in pixels. */ radius?: number color?: string weight?: number opacity?: number dashArray?: string | number[] fill?: boolean fillColor?: string fillOpacity?: number className?: string }>() const emit = defineEmits<{ (e: 'click', ev: L.LeafletMouseEvent): void }>() const pathOptions = () => defined({ color: props.color, weight: props.weight, opacity: props.opacity, dashArray: props.dashArray, fill: props.fill, fillColor: props.fillColor, fillOpacity: props.fillOpacity, className: props.className, interactive: true, } as L.CircleMarkerOptions) const layer = useLeafletLayer((map, L) => { const marker = L.circleMarker(toLatLng(props.center), { ...pathOptions(), radius: props.radius }) marker.on('click', (ev) => emit('click', ev)) return marker }) watch( () => props.center, (v) => { if (v) layer.value?.setLatLng(toLatLng(v)) }, ) watch( () => props.radius, (v) => { if (v !== undefined) layer.value?.setRadius(v) }, ) watch( () => [props.color, props.weight, props.opacity, props.fill, props.fillColor, props.fillOpacity, props.dashArray], () => layer.value?.setStyle(pathOptions()), ) </script> <template> <div class="hidden"><slot /></div> </template>app/components/ui/leaflet-map/LeafletGeoJson.vue0.9 kB
<script setup lang="ts"> import { watch } from 'vue' import type * as L from 'leaflet' import { useLeafletLayer } from './leaflet-context' defineOptions({ name: 'LeafletGeoJson' }) const props = defineProps<{ /** GeoJSON FeatureCollection / Feature / geometry. */ geojson: GeoJSON.GeoJSON /** Leaflet GeoJSON options: `style`, `pointToLayer`, `onEachFeature`, `filter`, `coordsToLatLng`. */ options?: L.GeoJSONOptions }>() const emit = defineEmits<{ (e: 'click', ev: L.LeafletMouseEvent): void }>() const layer = useLeafletLayer((map, L) => { const gj = L.geoJSON(props.geojson as any, props.options) gj.on('click', (ev) => emit('click', ev)) return gj }) watch( () => props.geojson, (v) => { if (!layer.value || !v) return layer.value.clearLayers() layer.value.addData(v as any) }, { deep: true }, ) </script> <template> <div class="hidden"><slot /></div> </template>app/components/ui/leaflet-map/LeafletTileLayer.vue1.1 kB
<script setup lang="ts"> import { watch } from 'vue' import { defined, useLeafletLayer } from './leaflet-context' defineOptions({ name: 'LeafletTileLayer' }) const props = defineProps<{ /** 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 layer = useLeafletLayer((map, L) => L.tileLayer( props.url, defined({ attribution: props.attribution, subdomains: props.subdomains, minZoom: props.minZoom, maxZoom: props.maxZoom, opacity: props.opacity, zIndex: props.zIndex, tms: props.tms, }), ), ) watch( () => props.url, (url) => { if (url) layer.value?.setUrl(url) }, ) watch( () => props.opacity, (v) => { if (v !== undefined) layer.value?.setOpacity(v) }, ) watch( () => props.zIndex, (v) => { if (v !== undefined) layer.value?.setZIndex(v) }, ) </script> <template> <div class="hidden" /> </template>app/components/ui/leaflet-map/leaflet-context.ts4.1 kB
import { inject, onUnmounted, provide, shallowRef, watch, type ShallowRef } from 'vue' import type * as L from 'leaflet' export const LEAFLET_MAP_KEY = 'uipkge-leaflet-map' export const LEAFLET_LAYER_KEY = 'uipkge-leaflet-layer' type LeafletModule = typeof import('leaflet') let leafletPromise: Promise<LeafletModule> | null = null /** * Lazily loads Leaflet's ESM build. Leaflet touches `window`/`document` at * import time, so a static top-level import would crash SSR renders — every * consumer of this helper resolves the module only on the client. */ export function loadLeaflet(): Promise<LeafletModule> { if (!leafletPromise) leafletPromise = import('leaflet') return leafletPromise } /** [lng, lat] (Mapbox order, matching the `map` component) -> Leaflet [lat, lng]. */ export function toLatLng(c: [number, number]): L.LatLngExpression { return [c[1], c[0]] } /** Converts a [lng, lat][] path to Leaflet [lat, lng][]. */ export 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) } /** [[west,south],[east,north]] in [lng, lat] -> a bounds literal Leaflet accepts. */ export function toLatLngBounds(bounds: [[number, number], [number, number]]): L.LatLngBoundsExpression { return [toLatLng(bounds[0]), toLatLng(bounds[1])] as L.LatLngBoundsExpression } export type LeafletPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' /** * Leaflet merges layer options by assignment, so passing `undefined` would * clobber its defaults (e.g. `subdomains: 'abc'` -> crash). Strip undefined * keys before handing options to Leaflet. */ export function defined<T extends object>(o: T): T { return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T } /** Injects the map instance ref published by the enclosing `<LeafletMap>`. */ export function useLeafletMap(): ShallowRef<L.Map | null> { const map = inject<ShallowRef<L.Map | null>>(LEAFLET_MAP_KEY) if (!map) { throw new Error('uipkge: Leaflet* components must be rendered inside <LeafletMap>.') } return map } /** * Waits for the enclosing `<LeafletMap>` instance, builds the layer once, adds * it to the map, and removes it on unmount. The layer is also provided so * nested `<LeafletPopup>` / `<LeafletTooltip>` children can bind to it. */ export function useLeafletLayer<T extends L.Layer>( build: (map: L.Map, L: LeafletModule) => T, opts: { addToMap?: boolean } = {}, ): ShallowRef<T | null> { const mapRef = useLeafletMap() const layer = shallowRef<T | null>(null) as ShallowRef<T | null> provide(LEAFLET_LAYER_KEY, layer as ShallowRef<L.Layer | null>) const stop = watch( mapRef, async (map) => { if (!map || layer.value) return const L = await loadLeaflet() if (layer.value) return const instance = build(map, L) layer.value = instance if (opts.addToMap !== false) instance.addTo(map) }, { immediate: true }, ) onUnmounted(() => { stop() layer.value?.remove() layer.value = null }) return layer } /** The nearest ancestor layer (marker, polyline, …) a popup/tooltip binds to. */ export function useParentLeafletLayer(): ShallowRef<L.Layer | null> | null { return inject<ShallowRef<L.Layer | null> | null>(LEAFLET_LAYER_KEY, null) } const LEAFLET_ICON_BASE = 'https://unpkg.com/leaflet@1.9.4/dist/images' let defaultIconFixed = false /** * Leaflet's default pin references image paths relative to the CSS file, which * bundlers can't resolve — point them at the versioned unpkg assets instead. * Only matters for markers without custom slot content. */ export 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`, }) }app/components/ui/leaflet-map/leaflet-map.variants.ts3.7 kB
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>app/components/ui/leaflet-map/index.ts1.2 kB
import type LeafletMap from './LeafletMap.vue' export { default as LeafletMap } from './LeafletMap.vue' /** Handle returned by a template ref on <LeafletMap> — mirrors the `map` * component's MapRef: camera helpers plus `map` / `getMap()` for the raw * Leaflet Map. */ export type LeafletMapRef = InstanceType<typeof LeafletMap> export { leafletMapVariants, LEAFLET_TILES, LEAFLET_THEME_TILES, type LeafletMapVariant, type LeafletMapVariants, type LeafletTilePreset, } from './leaflet-map.variants' export { default as LeafletMarker } from './LeafletMarker.vue' export { default as LeafletPopup } from './LeafletPopup.vue' export { default as LeafletTooltip } from './LeafletTooltip.vue' export { default as LeafletPolyline } from './LeafletPolyline.vue' export { default as LeafletPolygon } from './LeafletPolygon.vue' export { default as LeafletCircle } from './LeafletCircle.vue' export { default as LeafletCircleMarker } from './LeafletCircleMarker.vue' export { default as LeafletGeoJson } from './LeafletGeoJson.vue' export { default as LeafletTileLayer } from './LeafletTileLayer.vue' export { loadLeaflet, toLatLng, toLatLngs, toLatLngBounds, useLeafletMap } from './leaflet-context'app/components/ui/leaflet-map/leaflet-map.css3.7 kB
/** * 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/vue/leaflet-map-light.json