Marker Popups
leaflet-mapuiInteractive DOM marker pins with reactive token-styled popups, click-to-focus, and badge overlays — zero API key.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/leaflet-map.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/leaflet-map.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/leaflet-map.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/leaflet-map.jsonhttps://uipkge.dev/r/svelte/leaflet-map.jsonInstalls to:src/lib/components/ui/leaflet-map/Examples
Props
| 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 |
oncreatedFires with the raw `L.Map` once it is created. | (map: L.Map) => void | — | optional |
children | Snippet | — | optional |
ref | HTMLDivElement | null | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
LeafletMapContextinterface LeafletMapContext {
readonly map: L.Map | null
}LeafletLayerContextinterface LeafletLayerContext {
readonly current: L.Layer | null
}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)
src/lib/components/ui/leaflet-map/LeafletMap.svelte18.4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' import type * as L from 'leaflet' import type { LeafletMapVariant, LeafletMapVariants } from './leaflet-map.variants' import type { LeafletPosition } from './leaflet-context.svelte' export interface LeafletMapProps extends 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 `class` (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 /** Fires with the raw `L.Map` once it is created. */ oncreated?: (map: L.Map) => void children?: Snippet ref?: HTMLDivElement | null } 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 } /** Handle returned by `bind:this` on <LeafletMap> — mirrors the `map` * component's MapRef: camera helpers plus `getMap()` for the raw * Leaflet Map. React's `readonly map` property has no Svelte equivalent * (`bind:this` publishes functions, not live property bindings) — * `getMap()` covers it. */ export interface LeafletMapRef { getMap: () => L.Map | null flyTo: (options?: LeafletFlyToOptions) => void setView: (options?: LeafletViewOptions) => void jumpTo: (options?: LeafletViewOptions) => void /** [[west,south],[east,north]] in [lng, lat], or any Leaflet bounds expression. */ fitBounds: ( bounds: [[number, number], [number, number]] | L.LatLngBoundsExpression, options?: L.FitBoundsOptions, ) => void panTo: (center: [number, number]) => void zoomIn: () => void zoomOut: () => void resize: () => void } </script> <script 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 children to build any * map — the same composition model as the Mapbox `map` component. * * Not supported (Leaflet has no vector/GL renderer): pitch, bearing, 3D * buildings/terrain, globe projection. Use a `variant` preset or `tileUrl` * for custom raster tiles instead. */ import { onDestroy, onMount, untrack } from 'svelte' import { cn } from '$lib/utils' import { defined, fixDefaultLeafletIcon, loadLeaflet, setLeafletMapContext, toLatLng, toLatLngBounds, } from './leaflet-context.svelte' import type { LeafletTilePreset } from './leaflet-map.variants' import { leafletMapVariants, LEAFLET_TILES, LEAFLET_THEME_TILES, } from './leaflet-map.variants' import 'leaflet/dist/leaflet.css' import './leaflet-map.css' let { 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, ref = $bindable(null), class: className, ...restProps }: LeafletMapProps = $props() let htmlDark = $state(false) const isMuted = $derived(muted || variant === 'muted') const resolvedTiles = $derived.by<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 htmlDark ? LEAFLET_THEME_TILES.dark : LEAFLET_THEME_TILES.light }) // Client-only: Leaflet needs the DOM, so SSR renders just the bg-muted shell. let mounted = $state(false) let inView = $state(false) let mapEl = $state<HTMLDivElement | null>(null) let mapInstance = $state<L.Map | null>(null) let Lmod: typeof import('leaflet') | null = null let baseLayer: L.TileLayer | null = null let overlayLayer: L.TileLayer | null = null let lastTiles: LeafletTilePreset | null = null let resizeObserver: ResizeObserver | null = null let intersectionObserver: IntersectionObserver | null = null let themeObserver: MutationObserver | null = null // Published to LeafletMarker / LeafletPopup / … children. setLeafletMapContext({ get map() { return mapInstance }, }) function applyTiles(tiles: LeafletTilePreset) { const m = mapInstance const Ll = Lmod if (!m || !Ll) return baseLayer?.remove() baseLayer = null overlayLayer?.remove() overlayLayer = null baseLayer = Ll.tileLayer(tiles.url, { attribution: tiles.attribution, ...(tiles.subdomains ? { subdomains: tiles.subdomains } : {}), maxZoom: tiles.maxZoom ?? 19, }) baseLayer.addTo(m) if (tiles.overlayUrl) { overlayLayer = Ll.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. let attributions = $state<string[]>([]) let showAttribution = $state(false) function collectAttributions() { const m = mapInstance 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 = [...seen] } let canZoomIn = $state(true) let canZoomOut = $state(true) function syncZoomBounds() { const m = mapInstance if (!m) return canZoomIn = m.getZoom() < m.getMaxZoom() canZoomOut = m.getZoom() > m.getMinZoom() } function createMap() { const el = mapEl const Ll = Lmod if (!el || !Ll || mapInstance) return fixDefaultLeafletIcon(Ll) const tiles = resolvedTiles const m = Ll.map( el, defined({ center: toLatLng(center ?? [0, 20]), zoom, minZoom, maxZoom: maxZoom ?? tiles.maxZoom, zoomControl: false, attributionControl: false, scrollWheelZoom, }), ) mapInstance = m lastTiles = tiles applyTiles(tiles) m.on('zoomend', syncZoomBounds) syncZoomBounds() m.on('layeradd layerremove', collectAttributions) collectAttributions() oncreated?.(m) } onMount(() => { const root = document.documentElement const syncTheme = () => { htmlDark = root.classList.contains('dark') } syncTheme() themeObserver = new MutationObserver(syncTheme) themeObserver.observe(root, { attributes: true, attributeFilter: ['class'] }) mounted = true const el = ref if (!el || typeof IntersectionObserver === 'undefined') { inView = true } else { intersectionObserver = new IntersectionObserver( ([entry]) => { inView = entry!.isIntersecting }, { rootMargin: '160px', threshold: 0.01 }, ) intersectionObserver.observe(el) } if (typeof ResizeObserver !== 'undefined' && el) { resizeObserver = new ResizeObserver(() => { mapInstance?.invalidateSize() }) resizeObserver.observe(el) } const onFullscreenChange = () => { isFullscreen = Boolean(document.fullscreenElement) } document.addEventListener('fullscreenchange', onFullscreenChange) return () => { document.removeEventListener('fullscreenchange', onFullscreenChange) } }) onDestroy(() => { resizeObserver?.disconnect() intersectionObserver?.disconnect() themeObserver?.disconnect() try { mapInstance?.remove() } catch { /* already destroyed */ } mapInstance = null }) // Leaflet module loads lazily once the container is both mounted and in view. $effect(() => { if (!mounted || !inView || mapInstance) return let alive = true void (async () => { const Ll = await loadLeaflet() if (!alive) return Lmod = Ll createMap() })() return () => { alive = false } }) // Retile on variant / theme / custom-tile changes (skip the initial run — // createMap already applied them). $effect(() => { const tiles = resolvedTiles if (mapInstance && tiles !== lastTiles) { lastTiles = tiles applyTiles(tiles) } }) // Track 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. The map // is read untracked so creation (and any `oncreated` fitBounds) isn't undone. const centerLng = $derived(center?.[0]) const centerLat = $derived(center?.[1]) $effect(() => { const lng = centerLng const lat = centerLat const z = zoom const m = untrack(() => mapInstance) if (m && lng !== undefined && lat !== undefined) m.setView(toLatLng([lng, lat]), z) }) $effect(() => { if (!attribution) showAttribution = false }) $effect(() => { const enabled = scrollWheelZoom if (!mapInstance) return if (enabled) mapInstance.scrollWheelZoom.enable() else mapInstance.scrollWheelZoom.disable() }) let isFullscreen = $state(false) function toggleFullscreen() { const el = ref if (!el) return if (document.fullscreenElement) void document.exitFullscreen() else void 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 = ['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const const cornerClasses = $derived<Record<(typeof cornerOrder)[number], 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 navPosition = $derived(navigationPosition ?? 'bottom-right') const fsPosition = $derived(fullscreenPosition ?? 'top-right') /** Mapbox-style camera shim — accepts { center: [lng, lat], zoom, duration(ms) }. */ export function flyTo(options: LeafletFlyToOptions = {}) { const m = mapInstance if (!m) return const target = options.center ? toLatLng(options.center) : m.getCenter() m.flyTo(target, options.zoom ?? m.getZoom(), { duration: (options.duration ?? 800) / 1000, }) } export function setView(options: LeafletViewOptions = {}) { const m = mapInstance if (!m) return m.setView(options.center ? toLatLng(options.center) : m.getCenter(), options.zoom ?? m.getZoom()) } export function jumpTo(options: LeafletViewOptions = {}) { const m = mapInstance if (!m) return m.setView(options.center ? toLatLng(options.center) : m.getCenter(), options.zoom ?? m.getZoom(), { animate: false, }) } // `bind:this` on <LeafletMap> hands back these camera helpers plus // `getMap()` for the raw L.Map. It mirrors the `map` component's MapRef. /** The underlying Leaflet Map, or null until it is created. */ export function getMap() { return mapInstance } export function fitBounds( bounds: [[number, number], [number, number]] | L.LatLngBoundsExpression, options?: L.FitBoundsOptions, ) { mapInstance?.fitBounds(toLatLngBounds(bounds as [[number, number], [number, number]]), options) } export function panTo(center: [number, number]) { mapInstance?.panTo(toLatLng(center)) } export function zoomIn() { mapInstance?.zoomIn() } export function zoomOut() { mapInstance?.zoomOut() } export function resize() { mapInstance?.invalidateSize() } </script> <div bind:this={ref} data-uipkge="" data-slot="leaflet-map" data-variant={variant} data-muted={isMuted} class={cn(leafletMapVariants({ variant, ...(size ? { size } : {}) }), className)} {...restProps} > <div bind:this={mapEl} class="size-full" hidden={!inView}></div> {#each cornerOrder as corner (corner)} {#if mapInstance && ((navigation && navPosition === corner) || (fullscreen && fsPosition === corner))} <div class="absolute z-[1000] flex flex-col gap-2.5 {cornerClasses[corner]}"> {#if navigation && navPosition === corner} <div 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} onclick={() => mapInstance?.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} onclick={() => mapInstance?.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> {/if} {#if fullscreen && fsPosition === corner} <button 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" onclick={toggleFullscreen} > <svg class="size-full" viewBox="0 0 29 29" fill="currentColor" aria-hidden="true"> {#if 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" /> {:else} <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" /> {/if} </svg> </button> {/if} </div> {/if} {/each} <!-- Tile credits behind a Mapbox-style ⓘ button: hover reveals on desktop, tap toggles on touch. Keep visible — OSM/Esri tiles require credit. --> {#if mapInstance && attribution && attributions.length} <div 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 {showAttribution ? 'visible opacity-100' : 'invisible opacity-0 group-hover:visible group-hover:opacity-100'}" role="note" > {@html attributions.join(' | ')} </div> <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} onclick={() => (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> {/if} {@render children?.()} </div>src/lib/components/ui/leaflet-map/LeafletMarker.svelte2.2 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type * as L from 'leaflet' export type LeafletMarkerAnchor = | '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?: LeafletMarkerAnchor draggable?: boolean opacity?: number zIndexOffset?: number title?: string alt?: string /** Custom marker HTML. Omit for Leaflet's default pin. */ icon?: Snippet /** Overlay children (`<LeafletPopup>` / `<LeafletTooltip>`) bound to the marker. */ children?: Snippet onclick?: (ev: L.LeafletMouseEvent) => void onready?: (marker: L.Marker) => void } </script> <script lang="ts"> import { defined, toLatLng, useLeafletLayer } from './leaflet-context.svelte' let { lngLat, anchor = 'center', draggable, opacity, zIndexOffset, title, alt, icon, children, onclick, onready, }: LeafletMarkerProps = $props() let el = $state<HTMLElement | null>(null) const layer = useLeafletLayer((map, Ll) => { const options: L.MarkerOptions = defined({ interactive: true, draggable, opacity, zIndexOffset, title, alt, }) if (icon && el) { options.icon = Ll.divIcon({ className: 'uipkge-leaflet-div-icon', html: el }) } const marker = Ll.marker(toLatLng(lngLat), options) marker.on('click', (ev) => onclick?.(ev)) return marker }) $effect(() => { const m = layer.current if (m) onready?.(m) }) $effect(() => { const v = lngLat if (v) layer.current?.setLatLng(toLatLng(v)) }) $effect(() => { if (opacity !== undefined) layer.current?.setOpacity(opacity) }) $effect(() => { if (zIndexOffset !== undefined) layer.current?.setZIndexOffset(zIndexOffset) }) </script> <div bind:this={el} class="uipkge-leaflet-anchor" data-anchor={anchor}> {#if icon} {@render icon()} {/if} </div> {#if children} <div class="hidden">{@render children()}</div> {/if}src/lib/components/ui/leaflet-map/LeafletPopup.svelte2.3 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type * as L from 'leaflet' 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?: Snippet } </script> <script lang="ts"> import { onDestroy } from 'svelte' import { defined, loadLeaflet, toLatLng, useLeafletMap, useParentLeafletLayer } from './leaflet-context.svelte' let { lngLat, maxWidth, minWidth = 200, offset, className, autoClose, closeOnClick, closeButton, keepInView, children, }: LeafletPopupProps = $props() const mapCtx = useLeafletMap() const parentLayer = useParentLeafletLayer() let el = $state<HTMLElement | null>(null) let popup: L.Popup | null = null let boundTo: L.Layer | null = null $effect(() => { const m = mapCtx.map const layer = parentLayer?.current ?? null const node = el if (!m || !node) return let alive = true void (async () => { const Ll = await loadLeaflet() if (!alive) return const options = defined({ maxWidth, minWidth, offset, className, autoClose, closeOnClick, closeButton, keepInView }) if (layer) { // Re-bind if the parent layer instance was recreated. if (boundTo && boundTo !== layer) { try { boundTo.unbindPopup() } catch { /* already gone */ } } boundTo = layer layer.bindPopup(node, options) return } if (lngLat && !popup) { popup = Ll.popup(options).setLatLng(toLatLng(lngLat)).setContent(node) popup.openOn(m) } })() return () => { alive = false } }) onDestroy(() => { try { boundTo?.unbindPopup() popup?.remove() } catch { /* map already destroyed */ } boundTo = null popup = null }) </script> <div bind:this={el} class="uipkge-leaflet-popup-src">{@render children?.()}</div>src/lib/components/ui/leaflet-map/LeafletTooltip.svelte2.1 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type * as L from 'leaflet' 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?: Snippet } </script> <script lang="ts"> import { onDestroy } from 'svelte' import { defined, loadLeaflet, toLatLng, useLeafletMap, useParentLeafletLayer } from './leaflet-context.svelte' let { lngLat, offset, direction, permanent, sticky, opacity, className, interactive, children, }: LeafletTooltipProps = $props() const mapCtx = useLeafletMap() const parentLayer = useParentLeafletLayer() let el = $state<HTMLElement | null>(null) let tooltip: L.Tooltip | null = null let boundTo: L.Layer | null = null $effect(() => { const m = mapCtx.map const layer = parentLayer?.current ?? null const node = el if (!m || !node) return let alive = true void (async () => { const Ll = await loadLeaflet() if (!alive) return const options = defined({ offset, direction, permanent, sticky, opacity, className, interactive }) if (layer) { if (boundTo && boundTo !== layer) { try { boundTo.unbindTooltip() } catch { /* already gone */ } } boundTo = layer layer.bindTooltip(node, options) return } if (lngLat && !tooltip) { tooltip = Ll.tooltip(options).setLatLng(toLatLng(lngLat)).setContent(node) tooltip.addTo(m) } })() return () => { alive = false } }) onDestroy(() => { try { boundTo?.unbindTooltip() tooltip?.remove() } catch { /* map already destroyed */ } boundTo = null tooltip = null }) </script> <div bind:this={el} class="uipkge-leaflet-tooltip-src">{@render children?.()}</div>src/lib/components/ui/leaflet-map/LeafletPolyline.svelte1.8 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type * as L from 'leaflet' export interface LeafletPolylineProps { /** 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 children?: Snippet onclick?: (ev: L.LeafletMouseEvent) => void } </script> <script lang="ts"> import { defined, toLatLngs, useLeafletLayer } from './leaflet-context.svelte' let { lngLatPath, color, weight, opacity, lineCap, lineJoin, dashArray, dashOffset, smoothFactor, noClip, className, children, onclick, }: LeafletPolylineProps = $props() const pathOptions = () => defined({ color, weight, opacity, lineCap, lineJoin, dashArray, dashOffset, smoothFactor, noClip, className, interactive: true, } as L.PolylineOptions) const layer = useLeafletLayer((map, Ll) => { const line = Ll.polyline(toLatLngs(lngLatPath) as L.LatLngExpression[], pathOptions()) line.on('click', (ev) => onclick?.(ev)) return line }) $effect(() => { layer.current?.setLatLngs(toLatLngs(lngLatPath) as L.LatLngExpression[]) }) $effect(() => { // Touch every style prop so the effect re-runs when any of them changes. void [color, weight, opacity, dashArray, dashOffset, lineCap, lineJoin] layer.current?.setStyle(pathOptions()) }) </script> <div class="hidden">{@render children?.()}</div>src/lib/components/ui/leaflet-map/LeafletPolygon.svelte1.8 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type * as L from 'leaflet' export interface LeafletPolygonProps { /** 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 children?: Snippet onclick?: (ev: L.LeafletMouseEvent) => void } </script> <script lang="ts"> import { defined, toLatLngs, useLeafletLayer } from './leaflet-context.svelte' let { lngLatPath, color, weight, opacity, lineCap, lineJoin, dashArray, dashOffset, fill, fillColor, fillOpacity, className, children, onclick, }: LeafletPolygonProps = $props() const pathOptions = () => defined({ color, weight, opacity, lineCap, lineJoin, dashArray, dashOffset, fill, fillColor, fillOpacity, className, interactive: true, } as L.PolylineOptions) const layer = useLeafletLayer((map, Ll) => { const polygon = Ll.polygon(toLatLngs(lngLatPath) as L.LatLngExpression[], pathOptions()) polygon.on('click', (ev) => onclick?.(ev)) return polygon }) $effect(() => { layer.current?.setLatLngs(toLatLngs(lngLatPath) as L.LatLngExpression[]) }) $effect(() => { // Touch every style prop so the effect re-runs when any of them changes. void [color, weight, opacity, fill, fillColor, fillOpacity, dashArray] layer.current?.setStyle(pathOptions()) }) </script> <div class="hidden">{@render children?.()}</div>src/lib/components/ui/leaflet-map/LeafletCircle.svelte1.7 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type * as L from 'leaflet' export interface LeafletCircleProps { /** [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 children?: Snippet onclick?: (ev: L.LeafletMouseEvent) => void } </script> <script lang="ts"> import { defined, toLatLng, useLeafletLayer } from './leaflet-context.svelte' let { center, radius, color, weight, opacity, dashArray, fill, fillColor, fillOpacity, className, children, onclick, }: LeafletCircleProps = $props() const pathOptions = () => defined({ color, weight, opacity, dashArray, fill, fillColor, fillOpacity, className, interactive: true, } as L.CircleMarkerOptions) const layer = useLeafletLayer((map, Ll) => { const circle = Ll.circle(toLatLng(center), { ...pathOptions(), radius }) circle.on('click', (ev) => onclick?.(ev)) return circle }) $effect(() => { if (center) layer.current?.setLatLng(toLatLng(center)) }) $effect(() => { if (radius !== undefined) layer.current?.setRadius(radius) }) $effect(() => { // Touch every style prop so the effect re-runs when any of them changes. void [color, weight, opacity, fill, fillColor, fillOpacity, dashArray] layer.current?.setStyle(pathOptions()) }) </script> <div class="hidden">{@render children?.()}</div>src/lib/components/ui/leaflet-map/LeafletCircleMarker.svelte1.7 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type * as L from 'leaflet' export interface LeafletCircleMarkerProps { /** [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 children?: Snippet onclick?: (ev: L.LeafletMouseEvent) => void } </script> <script lang="ts"> import { defined, toLatLng, useLeafletLayer } from './leaflet-context.svelte' let { center, radius, color, weight, opacity, dashArray, fill, fillColor, fillOpacity, className, children, onclick, }: LeafletCircleMarkerProps = $props() const pathOptions = () => defined({ color, weight, opacity, dashArray, fill, fillColor, fillOpacity, className, interactive: true, } as L.CircleMarkerOptions) const layer = useLeafletLayer((map, Ll) => { const marker = Ll.circleMarker(toLatLng(center), { ...pathOptions(), radius }) marker.on('click', (ev) => onclick?.(ev)) return marker }) $effect(() => { if (center) layer.current?.setLatLng(toLatLng(center)) }) $effect(() => { if (radius !== undefined) layer.current?.setRadius(radius) }) $effect(() => { // Touch every style prop so the effect re-runs when any of them changes. void [color, weight, opacity, fill, fillColor, fillOpacity, dashArray] layer.current?.setStyle(pathOptions()) }) </script> <div class="hidden">{@render children?.()}</div>src/lib/components/ui/leaflet-map/LeafletGeoJson.svelte1 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type * as L from 'leaflet' export interface LeafletGeoJsonProps { /** GeoJSON FeatureCollection / Feature / geometry. */ geojson: GeoJSON.GeoJSON /** Leaflet GeoJSON options: `style`, `pointToLayer`, `onEachFeature`, `filter`, `coordsToLatLng`. */ options?: L.GeoJSONOptions children?: Snippet onclick?: (ev: L.LeafletMouseEvent) => void } </script> <script lang="ts"> import { useLeafletLayer } from './leaflet-context.svelte' let { geojson, options, children, onclick }: LeafletGeoJsonProps = $props() const layer = useLeafletLayer((map, Ll) => { const gj = Ll.geoJSON(geojson as any, options) gj.on('click', (ev) => onclick?.(ev)) return gj }) $effect(() => { const v = geojson if (!layer.current || !v) return layer.current.clearLayers() layer.current.addData(v as any) }) </script> <div class="hidden">{@render children?.()}</div>src/lib/components/ui/leaflet-map/LeafletTileLayer.svelte1.1 kB
<script lang="ts" module> import type * as L from 'leaflet' 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 } </script> <script lang="ts"> import { defined, useLeafletLayer } from './leaflet-context.svelte' let { url, attribution, subdomains, minZoom, maxZoom, opacity, zIndex, tms }: LeafletTileLayerProps = $props() const layer = useLeafletLayer((map, Ll) => Ll.tileLayer( url, defined({ attribution, subdomains, minZoom, maxZoom, opacity, zIndex, tms, }), ), ) $effect(() => { if (url) layer.current?.setUrl(url) }) $effect(() => { if (opacity !== undefined) layer.current?.setOpacity(opacity) }) $effect(() => { if (zIndex !== undefined) layer.current?.setZIndex(zIndex) }) </script> <div class="hidden"></div>src/lib/components/ui/leaflet-map/leaflet-context.svelte.ts5 kB
import { getContext, onDestroy, setContext } from 'svelte' 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 } /** Reactive view of the enclosing `<LeafletMap>` instance. Reads track, so an * `$effect` re-runs when the map is created. */ export interface LeafletMapContext { readonly map: L.Map | null } /** Published by `<LeafletMap>` once per instance. Must be called during init. */ export function setLeafletMapContext(ctx: LeafletMapContext): void { setContext(LEAFLET_MAP_KEY, ctx) } /** Injects the map context published by the enclosing `<LeafletMap>`. */ export function useLeafletMap(): LeafletMapContext { const ctx = getContext<LeafletMapContext>(LEAFLET_MAP_KEY) if (!ctx) { throw new Error('uipkge: Leaflet* components must be rendered inside <LeafletMap>.') } return ctx } /** Reactive view of the nearest ancestor layer a popup/tooltip binds to. */ export interface LeafletLayerContext { readonly current: L.Layer | null } /** Reactive view of a layer built by `useLeafletLayer`, keeping its concrete type. */ export interface LeafletLayerRef<T extends L.Layer> { readonly current: T | null } /** * 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. * Must be called during component init. */ export function useLeafletLayer<T extends L.Layer>( build: (map: L.Map, leaflet: LeafletModule) => T, opts: { addToMap?: boolean } = {}, ): LeafletLayerRef<T> { const mapCtx = useLeafletMap() let layer = $state<T | null>(null) const ctx: LeafletLayerRef<T> = { get current(): T | null { return layer }, } setContext(LEAFLET_LAYER_KEY, ctx satisfies LeafletLayerContext) let cancelled = false $effect(() => { const map = mapCtx.map if (!map || layer) return let alive = true void (async () => { const Ll = await loadLeaflet() if (!alive || cancelled || layer) return const instance = build(map, Ll) layer = instance if (opts.addToMap !== false) instance.addTo(map) })() return () => { alive = false } }) onDestroy(() => { cancelled = true try { layer?.remove() } catch { /* map already destroyed */ } layer = null }) return ctx } /** The nearest ancestor layer (marker, polyline, …) a popup/tooltip binds to. */ export function useParentLeafletLayer(): LeafletLayerContext | null { return getContext<LeafletLayerContext | 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(Ll: LeafletModule) { if (defaultIconFixed) return defaultIconFixed = true Ll.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`, }) }src/lib/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>src/lib/components/ui/leaflet-map/index.ts1.3 kB
export { default as LeafletMap, type LeafletMapProps, type LeafletMapRef } from './LeafletMap.svelte' export { leafletMapVariants, LEAFLET_TILES, LEAFLET_THEME_TILES, type LeafletMapVariant, type LeafletMapVariants, type LeafletTilePreset, } from './leaflet-map.variants' export { default as LeafletMarker, type LeafletMarkerProps, type LeafletMarkerAnchor } from './LeafletMarker.svelte' export { default as LeafletPopup, type LeafletPopupProps } from './LeafletPopup.svelte' export { default as LeafletTooltip, type LeafletTooltipProps } from './LeafletTooltip.svelte' export { default as LeafletPolyline, type LeafletPolylineProps } from './LeafletPolyline.svelte' export { default as LeafletPolygon, type LeafletPolygonProps } from './LeafletPolygon.svelte' export { default as LeafletCircle, type LeafletCircleProps } from './LeafletCircle.svelte' export { default as LeafletCircleMarker, type LeafletCircleMarkerProps } from './LeafletCircleMarker.svelte' export { default as LeafletGeoJson, type LeafletGeoJsonProps } from './LeafletGeoJson.svelte' export { default as LeafletTileLayer, type LeafletTileLayerProps } from './LeafletTileLayer.svelte' export { loadLeaflet, toLatLng, toLatLngs, toLatLngBounds, useLeafletMap, type LeafletPosition, } from './leaflet-context.svelte'src/lib/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/svelte/leaflet-map-marker-popups.json