Turn-by-Turn HUD
mapuiIn-dash vehicle routing display with real-time speed telemetry, speed limit badge, and heading compass.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/map.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/map.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/map.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/map.jsonhttps://uipkge.dev/r/svelte/map.jsonInstalls to:src/lib/components/ui/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 |
accessTokenMapbox access token. | string | — | optional |
mapStyleOverride the base style. Defaults to variant or theme-aware light/dark style. | string | — | optional |
centerInitial [lng, lat]. | [number, number] | — | optional |
zoom | number | 1 | optional |
pitchCamera pitch tilt angle in degrees (0 - 85). | number | 0 | optional |
bearingCamera bearing rotation angle in degrees (0 - 360). | number | 0 | optional |
buildings3dAutomatically add 3D extruded building footprints. | boolean | false | optional |
terrain3dEnable 3D digital elevation model terrain mesh. | boolean | false | optional |
projection'mercator' (flat) or 'globe'. | string | mercator | optional |
navigationShow the zoom/compass control. | boolean | true | optional |
navigationPositionPlacement position of the navigation control. | MapControlPosition | bottom-right | optional |
showCompassExplicitly show/hide compass in navigation control. Defaults to true when pitch > 0. | boolean | — | optional |
showZoomExplicitly show/hide zoom buttons in navigation control. Defaults to true. | boolean | true | optional |
fullscreenShow the HTML5 fullscreen toggle control button. | boolean | false | optional |
fullscreenPositionPlacement position of the fullscreen control. Defaults to 'top-right'. | MapControlPosition | top-right | optional |
mutedRepaint the basemap to a quiet, desaturated canvas. | boolean | false | optional |
oncreatedFires with the raw mapbox-gl `Map` once the style is ready. | (map: mapboxgl.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.
MapContextinterface MapContext {
readonly map: mapboxgl.Map | null
}npm dependencies
Files installed (11)
src/lib/components/ui/map/Map.svelte16.2 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' // Module-script imports are visible to the instance script; importing the // value here (instead of `import type` + a second instance import) avoids a // duplicate-identifier error — both scripts share one scope. import mapboxgl from 'mapbox-gl' import type { MapVariant, MapVariants } from './map.variants' export type MapControlPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' export interface MapProps extends HTMLAttributes<HTMLDivElement> { /** Mapbox access token. */ accessToken?: string /** Named Mapbox basemap variant preset ('streets' | 'outdoors' | 'satellite' | 'satellite-streets' | 'light' | 'dark' | 'navigation-day' | 'navigation-night' | 'standard' | 'muted' | 'default'). */ variant?: MapVariant /** Height preset. Omit to size via `class` (blocks typically pass `size-full`). */ size?: MapVariants['size'] /** Override the base style. Defaults to variant or theme-aware light/dark style. */ mapStyle?: string /** Initial [lng, lat]. */ center?: [number, number] zoom?: number /** Camera pitch tilt angle in degrees (0 - 85). */ pitch?: number /** Camera bearing rotation angle in degrees (0 - 360). */ bearing?: number /** Automatically add 3D extruded building footprints. */ buildings3d?: boolean /** Enable 3D digital elevation model terrain mesh. */ terrain3d?: boolean /** 'mercator' (flat) or 'globe'. */ projection?: string /** Show the zoom/compass control. */ navigation?: boolean /** Placement position of the navigation control. */ navigationPosition?: MapControlPosition /** Explicitly show/hide compass in navigation control. Defaults to true when pitch > 0. */ showCompass?: boolean /** Explicitly show/hide zoom buttons in navigation control. Defaults to true. */ showZoom?: boolean /** Show the HTML5 fullscreen toggle control button. */ fullscreen?: boolean /** Placement position of the fullscreen control. Defaults to 'top-right'. */ fullscreenPosition?: MapControlPosition /** Repaint the basemap to a quiet, desaturated canvas. */ muted?: boolean /** Fires with the raw mapbox-gl `Map` once the style is ready. */ oncreated?: (map: mapboxgl.Map) => void children?: Snippet ref?: HTMLDivElement | null } /** Handle returned by `bind:this` on <Map> — mirrors react-map-gl's MapRef. * Carries the camera helpers plus `getMap()` for the raw mapbox-gl Map. */ export interface MapRef { getMap: () => mapboxgl.Map | null flyTo: (...args: Parameters<mapboxgl.Map['flyTo']>) => void easeTo: (...args: Parameters<mapboxgl.Map['easeTo']>) => void jumpTo: (...args: Parameters<mapboxgl.Map['jumpTo']>) => void fitBounds: (...args: Parameters<mapboxgl.Map['fitBounds']>) => void resize: () => void } </script> <script lang="ts"> /** * Map — a thin, theme-aware Mapbox GL JS wrapper. Pass an `accessToken`; * drop `<MapMarker>` / `<MapPopup>` / `<MapSource>` / `<MapLayer>` into the * children to build any kind of map. * * Requires a Mapbox access token (https://account.mapbox.com). */ import { onDestroy, onMount } from 'svelte' import { cn } from '$lib/utils' import { setMapContext } from './map-context.svelte' import { mapVariants, MAPBOX_STYLES } from './map.variants' import 'mapbox-gl/dist/mapbox-gl.css' import './map.css' const DEFAULT_MAPBOX_TOKEN = '' function envToken(): string { return ( (typeof process !== 'undefined' && (process.env?.NEXT_PUBLIC_MAPBOX_TOKEN || process.env?.PUBLIC_MAPBOX_TOKEN)) || (typeof import.meta !== 'undefined' && (import.meta as any).env?.PUBLIC_MAPBOX_TOKEN) || DEFAULT_MAPBOX_TOKEN ) } let { accessToken = envToken(), variant = 'default', size, mapStyle, center = [0, 20], zoom = 1.4, pitch = 0, bearing = 0, buildings3d = false, terrain3d = false, projection = 'mercator', navigation = true, navigationPosition = 'bottom-right', showCompass, showZoom = true, fullscreen = false, fullscreenPosition = 'top-right', muted = false, oncreated, children, ref = $bindable(null), class: className, ...restProps }: MapProps = $props() const resolvedToken = $derived.by(() => { // Explicit empty string opts out of the demo fallback — used by "missing token" stories. if (accessToken === '') return '' return accessToken || envToken() }) let htmlDark = $state(false) const isDark = () => htmlDark const isMuted = $derived(muted || variant === 'muted') const resolvedStyle = $derived.by(() => { if (mapStyle) return mapStyle if ( variant && variant !== 'default' && variant !== 'muted' && MAPBOX_STYLES[variant as keyof typeof MAPBOX_STYLES] ) { return MAPBOX_STYLES[variant as keyof typeof MAPBOX_STYLES] } return htmlDark ? 'mapbox://styles/mapbox/dark-v11' : 'mapbox://styles/mapbox/light-v11' }) // Render the map only on the client — Mapbox needs the DOM, and rendering it // on the server would hydrate-mismatch. The bg-muted box is the SSR placeholder. let mounted = $state(false) let inView = $state(false) let mapEl = $state<HTMLDivElement | null>(null) let mapInstance = $state<mapboxgl.Map | null>(null) let lastStyle: string | null = null let resizeObserver: ResizeObserver | null = null let intersectionObserver: IntersectionObserver | null = null let themeObserver: MutationObserver | null = null // Published to MapMarker / MapPopup / MapSource / MapLayer children. setMapContext({ get map() { return mapInstance }, }) 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 if (!ref) { inView = true return } if (typeof IntersectionObserver !== 'undefined') { // Latch: the observer only defers the first paint. Un-setting it on scroll // would tear the WebGL map down and rebuild it (fresh style fetch, camera // reset, markers re-laid-out against a stale canvas size) every time the // block leaves the viewport. intersectionObserver = new IntersectionObserver( ([entry]) => { if (!entry!.isIntersecting) return inView = true intersectionObserver?.disconnect() intersectionObserver = null }, { rootMargin: '160px', threshold: 0.01 }, ) intersectionObserver.observe(ref) } else { inView = true } if (typeof ResizeObserver !== 'undefined' && ref) { const container = ref resizeObserver = new ResizeObserver(() => { mapInstance?.resize() }) resizeObserver.observe(container) } }) onDestroy(() => { resizeObserver?.disconnect() resizeObserver = null intersectionObserver?.disconnect() intersectionObserver = null themeObserver?.disconnect() themeObserver = null try { mapInstance?.remove() } catch { /* already destroyed */ } mapInstance = null }) /** Desaturate the basemap to a quiet canvas. Each op is guarded — style layer * ids drift between style versions and the wrong property for a layer type * throws. */ function applyMuted() { const map = mapInstance if (!map) return let styleLayers: any[] try { styleLayers = map.getStyle()?.layers ?? [] } catch { return // style not loaded yet } const dark = isDark() const P = dark ? { land: 'rgb(24,24,27)', water: 'rgb(39,39,42)', use: 'rgb(30,30,34)', road: 'rgb(45,46,52)', label: 'rgb(161,161,170)', halo: 'rgb(24,24,27)', admin: 'rgb(50,52,60)', } : { land: 'rgb(246,247,249)', water: 'rgb(226,230,235)', use: 'rgb(238,240,243)', road: 'rgb(221,224,229)', label: 'rgb(120,124,134)', halo: 'rgb(246,247,249)', admin: 'rgb(213,216,221)', } for (const l of styleLayers) { const id = l.id try { if (id === 'background' || id === 'land') map.setPaintProperty(id, 'background-color', P.land) else if (/water/.test(id) && l.type === 'fill') map.setPaintProperty(id, 'fill-color', P.water) else if (/landuse|landcover|national-park/.test(id) && l.type === 'fill') map.setPaintProperty(id, 'fill-color', P.use) else if (/^road-(motorway|trunk|primary)/.test(id) && l.type === 'line') { map.setPaintProperty(id, 'line-color', P.road) map.setPaintProperty(id, 'line-opacity', 0.55) } else if (/^road-(secondary|tertiary|street|minor|service|path|pedestrian)/.test(id)) { map.setLayoutProperty(id, 'visibility', 'none') } else if (/poi|transit|airport|natural-point|water-point|waterway-label|building/.test(id)) { map.setLayoutProperty(id, 'visibility', 'none') } else if ( /road-label|settlement-major-label|settlement-minor-label|state-label/.test(id) && l.type === 'symbol' ) { map.setPaintProperty(id, 'text-color', P.label) map.setPaintProperty(id, 'text-halo-color', P.halo) map.setPaintProperty(id, 'text-opacity', 0.6) } else if (id === 'admin-1-boundary' && l.type === 'line') { map.setPaintProperty(id, 'line-color', P.admin) map.setPaintProperty(id, 'line-opacity', 0.6) } else if (id === 'admin-1-boundary-bg') map.setPaintProperty(id, 'line-opacity', 0) } catch { /* skip */ } } } function apply3DBuildings() { const map = mapInstance if (!map || !buildings3d) return if (map.getLayer('3d-buildings')) return const layers = map.getStyle()?.layers let labelLayerId: string | undefined if (layers) { for (let i = 0; i < layers.length; i++) { if (layers[i].type === 'symbol' && (layers[i].layout as any)?.['text-field']) { labelLayerId = layers[i].id break } } } try { map.addLayer( { id: '3d-buildings', source: 'composite', 'source-layer': 'building', filter: ['==', 'extrude', 'true'], type: 'fill-extrusion', minzoom: 14, paint: { 'fill-extrusion-color': isDark() ? '#27272a' : '#d4d4d8', 'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 14, 0, 14.05, ['get', 'height']], 'fill-extrusion-base': ['interpolate', ['linear'], ['zoom'], 14, 0, 14.05, ['get', 'min_height']], 'fill-extrusion-opacity': 0.8, }, }, labelLayerId, ) } catch { /* building layer not present in raster styles */ } } function apply3DTerrain() { const map = mapInstance if (!map || !terrain3d) return try { if (!map.getSource('mapbox-dem')) { map.addSource('mapbox-dem', { type: 'raster-dem', url: 'mapbox://mapbox.mapbox-terrain-dem-v1', tileSize: 512, maxzoom: 14, }) } map.setTerrain({ source: 'mapbox-dem', exaggeration: 1.5 }) } catch { /* terrain not supported in current projection */ } } let navControl: mapboxgl.NavigationControl | null = null let fullscreenControl: mapboxgl.FullscreenControl | null = null function updateNavControl() { const map = mapInstance if (!map) return if (navControl) { try { map.removeControl(navControl) } catch { /* ignore */ } navControl = null } if (navigation) { const compass = showCompass !== undefined ? showCompass : pitch > 0 navControl = new mapboxgl.NavigationControl({ showCompass: compass, showZoom }) map.addControl(navControl, navigationPosition ?? 'bottom-right') } } function updateFullscreenControl() { const map = mapInstance if (!map) return if (fullscreenControl) { try { map.removeControl(fullscreenControl) } catch { /* ignore */ } fullscreenControl = null } if (fullscreen) { fullscreenControl = new mapboxgl.FullscreenControl() map.addControl(fullscreenControl, fullscreenPosition ?? 'top-right') } } function createMap() { const el = mapEl const token = resolvedToken if (!el || !token || mapInstance) return const map = new mapboxgl.Map({ container: el, accessToken: token, style: resolvedStyle, center, zoom, attributionControl: false, }) mapInstance = map lastStyle = resolvedStyle // The ResizeObserver's first callback runs before this instance exists, so a // container that settled its height during mount would leave the canvas -- // and every marker laid out against it -- at the stale size. map.resize() try { ;(map as any).setProjection(projection) } catch { /* older api */ } if (pitch) { try { map.setPitch(pitch) } catch { /* ignore */ } } if (bearing) { try { map.setBearing(bearing) } catch { /* ignore */ } } updateNavControl() updateFullscreenControl() const ready = () => { if (isMuted) applyMuted() if (buildings3d) apply3DBuildings() if (terrain3d) apply3DTerrain() oncreated?.(map) } if (map.isStyleLoaded()) ready() else map.once('load', ready) map.on('style.load', () => { if (isMuted) applyMuted() if (buildings3d) apply3DBuildings() if (terrain3d) apply3DTerrain() }) } $effect(() => { if (mounted && inView && resolvedToken && mapEl && !mapInstance) createMap() }) // Re-style on variant / theme / mapStyle changes (skip the initial run — // createMap already applied it). $effect(() => { const style = resolvedStyle if (mapInstance && style !== lastStyle) { lastStyle = style try { mapInstance.setStyle(style) } catch { /* style still configuring */ } } }) $effect(() => { if (mapInstance && pitch !== undefined) mapInstance.setPitch(pitch) }) $effect(() => { if (mapInstance && bearing !== undefined) mapInstance.setBearing(bearing) }) $effect(() => { void [navigation, navigationPosition, showCompass, showZoom] updateNavControl() }) $effect(() => { void [fullscreen, fullscreenPosition] updateFullscreenControl() }) // `bind:this` on <Map> hands back the mapbox-gl Map itself, plus the camera // helpers blocks reach for directly off the ref. /** The underlying mapbox-gl Map, or null until it is created. */ export function getMap() { return mapInstance } // Option types are read off the Map class so they track mapbox-gl's own // signatures instead of drifting against renamed exports. export function flyTo(...args: Parameters<mapboxgl.Map['flyTo']>) { mapInstance?.flyTo(...args) } export function easeTo(...args: Parameters<mapboxgl.Map['easeTo']>) { mapInstance?.easeTo(...args) } export function jumpTo(...args: Parameters<mapboxgl.Map['jumpTo']>) { mapInstance?.jumpTo(...args) } export function fitBounds(...args: Parameters<mapboxgl.Map['fitBounds']>) { mapInstance?.fitBounds(...args) } export function resize() { mapInstance?.resize() } </script> <div bind:this={ref} data-uipkge="" data-slot="map" data-variant={variant} data-muted={isMuted} class={cn(mapVariants({ variant, ...(size ? { size } : {}) }), className)} {...restProps} > {#if mounted && inView && resolvedToken} <div bind:this={mapEl} class="size-full"></div> {@render children?.()} {:else if mounted && !resolvedToken} <div class="text-muted-foreground flex size-full flex-col items-center justify-center gap-1 px-6 text-center text-sm" > <span class="text-foreground font-medium">Mapbox token required</span> <span>Pass an access token to render the map.</span> </div> {/if} </div>src/lib/components/ui/map/MapSource.svelte2.4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { AnySourceData, GeoJSONSource } from 'mapbox-gl' export interface MapSourceProps { id: string type?: string data?: any options?: AnySourceData children?: Snippet } </script> <script lang="ts"> import { setMapSourceId, useMap } from './map-context.svelte' let { id, type, data, options, children }: MapSourceProps = $props() const mapCtx = useMap() // svelte-ignore state_referenced_locally — the source id is intentionally initial-only (matches Vue's provide). setMapSourceId(id) function getSourceOptions(): AnySourceData { if (options) return options return { type: (type || 'geojson') as any, data, } } function addOrUpdateSource(mapInstance: NonNullable<typeof mapCtx.map>) { try { if (mapInstance.getSource(id)) { const src = mapInstance.getSource(id) as GeoJSONSource | undefined if (src && typeof src.setData === 'function' && data) { src.setData(data) } return } mapInstance.addSource(id, getSourceOptions()) } catch { // Retry on next frame if style is still configuring } } function removeSource(mapInstance: NonNullable<typeof mapCtx.map>) { try { if (mapInstance.getSource(id)) { const layers = mapInstance.getStyle()?.layers || [] for (const l of layers) { if ('source' in l && l.source === id) { mapInstance.removeLayer(l.id) } } mapInstance.removeSource(id) } } catch { // Ignored during map destruction } } $effect(() => { const mapInstance = mapCtx.map if (!mapInstance) return if (mapInstance.isStyleLoaded()) { addOrUpdateSource(mapInstance) } else { mapInstance.once('style.load', () => addOrUpdateSource(mapInstance)) mapInstance.once('load', () => addOrUpdateSource(mapInstance)) } return () => removeSource(mapInstance) }) $effect(() => { const newData = data const mapInstance = mapCtx.map if (!mapInstance || !newData) return try { const src = mapInstance.getSource(id) as GeoJSONSource | undefined if (src && typeof src.setData === 'function') { src.setData(newData) } } catch { // Ignored if source not yet ready or re-rendering } }) </script> <div {id} style="display: contents">{@render children?.()}</div>src/lib/components/ui/map/MapLayer.svelte2.8 kB
<script lang="ts" module> import type { AnyLayer } from 'mapbox-gl' export interface MapLayerProps { id: string /** Optional: `options` carries its own `type`, so only the flat form needs it. */ type?: string source?: string paint?: Record<string, any> layout?: Record<string, any> beforeId?: string options?: any } </script> <script lang="ts"> import { useMap, useMapSourceId } from './map-context.svelte' let { id, type, source, paint, layout, beforeId, options }: MapLayerProps = $props() const parentSourceId = useMapSourceId() const mapCtx = useMap() function getLayerConfig(): AnyLayer { if (options) return { ...options, id } return { id, type: type as any, source: source || parentSourceId, paint: paint || {}, layout: layout || {}, } as AnyLayer } function addLayerSafe(mapInstance: NonNullable<typeof mapCtx.map>) { try { if (mapInstance.getLayer(id)) { mapInstance.removeLayer(id) } const config = getLayerConfig() if (config.source && !mapInstance.getSource(config.source as string)) { // Source not ready yet, wait for sourcedata event const checkAndAdd = () => { try { if (!mapInstance.getLayer(id) && mapInstance.getSource(config.source as string)) { mapInstance.addLayer(config, beforeId) mapInstance.off('sourcedata', checkAndAdd) } } catch { // Ignored } } mapInstance.on('sourcedata', checkAndAdd) return } mapInstance.addLayer(config, beforeId) } catch { // Retry if style loading } } $effect(() => { const mapInstance = mapCtx.map if (!mapInstance) return if (mapInstance.isStyleLoaded()) { addLayerSafe(mapInstance) } else { mapInstance.once('style.load', () => addLayerSafe(mapInstance)) mapInstance.once('load', () => addLayerSafe(mapInstance)) } return () => { try { if (mapInstance.getLayer(id)) { mapInstance.removeLayer(id) } } catch { // Ignored } } }) $effect(() => { const newPaint = paint const mapInstance = mapCtx.map if (!mapInstance || !newPaint) return try { if (mapInstance.getLayer(id)) { for (const [key, value] of Object.entries(newPaint)) { // Keys come from a caller-supplied record; mapbox generics the name // against a closed union of every paint property, which a plain // string cannot satisfy. ;(mapInstance.setPaintProperty as (layer: string, name: string, value: unknown) => void)(id, key, value) } } } catch { // Ignored } }) </script> <div {id} style="display: none"></div>src/lib/components/ui/map/MapMarker.svelte3.8 kB
<script lang="ts" module> import type { Snippet } from 'svelte' // Value import in the module script (visible to the instance script) — both // scripts share one scope, so a second instance import would collide. import mapboxgl from 'mapbox-gl' export interface MapMarkerProps { /** [lng, lat] position. */ lngLat: [number, number] /** Default-pin colour (only when no `children` custom content is given). */ color?: string /** Scale factor for the default pin. */ scale?: number draggable?: boolean rotation?: number rotationAlignment?: 'map' | 'viewport' | 'auto' pitchAlignment?: 'map' | 'viewport' | 'auto' /** Which part of the marker sits on the coordinates. Passed to Mapbox. */ anchor?: | 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' /** Class applied to the custom-content wrapper (only rendered with `children`). */ class?: string /** Custom marker content. Omit for the default Mapbox pin. */ children?: Snippet /** Fires after the marker is dragged. */ ondragend?: (ev: { target: mapboxgl.Marker }) => void onclick?: (ev: MouseEvent) => void } </script> <script lang="ts"> import { untrack } from 'svelte' import { useMap } from './map-context.svelte' let { lngLat, color, scale, draggable, rotation, rotationAlignment, pitchAlignment, anchor, class: className, children, ondragend, onclick, }: MapMarkerProps = $props() const mapCtx = useMap() let el = $state<HTMLElement | null>(null) let marker: mapboxgl.Marker | null = null $effect(() => { const mapInstance = mapCtx.map const node = el // `children` decides default pin vs custom element; read it so the marker // rebuilds if custom content appears after mount. Everything else is // initial-only — position syncs via the effect below, so option/callback // identity churn must not recreate the marker. const hasCustomContent = Boolean(children) if (!mapInstance || (hasCustomContent && !node)) return const initial = untrack(() => ({ lngLat, color, scale, draggable, rotation, rotationAlignment, pitchAlignment, anchor, ondragend, onclick, })) marker = new mapboxgl.Marker({ ...(hasCustomContent && node ? { element: node } : {}), ...(initial.color ? { color: initial.color } : {}), ...(initial.scale !== undefined ? { scale: initial.scale } : {}), ...(initial.draggable !== undefined ? { draggable: initial.draggable } : {}), ...(initial.rotation !== undefined ? { rotation: initial.rotation } : {}), ...(initial.rotationAlignment ? { rotationAlignment: initial.rotationAlignment } : {}), ...(initial.pitchAlignment ? { pitchAlignment: initial.pitchAlignment } : {}), ...(initial.anchor ? { anchor: initial.anchor } : {}), }) .setLngLat(initial.lngLat) .addTo(mapInstance) if (initial.ondragend) { const cb = initial.ondragend marker.on('dragend', () => cb({ target: marker! })) } const markerNode = marker.getElement() const handleClick = (ev: MouseEvent) => initial.onclick?.(ev) if (initial.onclick) markerNode.addEventListener('click', handleClick) const instance = marker return () => { if (onclick) markerNode.removeEventListener('click', handleClick) instance.remove() if (marker === instance) marker = null } }) $effect(() => { if (marker && lngLat) marker.setLngLat(lngLat) }) </script> {#if children} <!-- Plain wrapper (no display:contents): Mapbox positions this element with a transform, which needs a real box — same box the `class` prop styles. --> <div bind:this={el} class={className}>{@render children()}</div> {/if}src/lib/components/ui/map/MapPopup.svelte2.2 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface MapPopupProps { /** [lng, lat] anchor. */ lngLat: [number, number] closeButton?: boolean closeOnClick?: boolean closeOnMove?: boolean maxWidth?: string className?: string offset?: number | [number, number] children?: Snippet onopen?: () => void onclose?: () => void } </script> <script lang="ts"> import { untrack } from 'svelte' import mapboxgl from 'mapbox-gl' import { useMap } from './map-context.svelte' let { lngLat, closeButton, closeOnClick, closeOnMove, maxWidth, className, offset, children, onopen, onclose, }: MapPopupProps = $props() const mapCtx = useMap() let el = $state<HTMLElement | null>(null) let popup: mapboxgl.Popup | null = null $effect(() => { const mapInstance = mapCtx.map const node = el if (!mapInstance || !node) return // Options are initial-only; position syncs via the effect below. const initial = untrack(() => ({ lngLat, closeButton, closeOnClick, closeOnMove, maxWidth, className, offset, onopen, onclose, })) popup = new mapboxgl.Popup({ ...(initial.closeButton !== undefined ? { closeButton: initial.closeButton } : {}), ...(initial.closeOnClick !== undefined ? { closeOnClick: initial.closeOnClick } : {}), ...(initial.closeOnMove !== undefined ? { closeOnMove: initial.closeOnMove } : {}), ...(initial.maxWidth ? { maxWidth: initial.maxWidth } : {}), ...(initial.className ? { className: initial.className } : {}), ...(initial.offset !== undefined ? { offset: initial.offset } : {}), }) .setLngLat(initial.lngLat) .setDOMContent(node) .addTo(mapInstance) if (initial.onopen) popup.on('open', initial.onopen) if (initial.onclose) popup.on('close', initial.onclose) const instance = popup return () => { instance.remove() if (popup === instance) popup = null } }) $effect(() => { if (popup && lngLat) popup.setLngLat(lngLat) }) </script> <div bind:this={el}>{@render children?.()}</div>src/lib/components/ui/map/MapNavigationControl.svelte1.9 kB
<script lang="ts" module> // Value import in the module script (visible to the instance script) — both // scripts share one scope, so a second instance import would collide. import mapboxgl from 'mapbox-gl' import type { MapControlPosition } from './Map.svelte' export interface MapNavigationControlProps { /** Control placement. Defaults to 'top-right' (react-map-gl parity). */ position?: MapControlPosition /** Show the compass button. Defaults to true. */ showCompass?: boolean /** Show the zoom buttons. Defaults to true. */ showZoom?: boolean /** Tilt the compass to reflect the camera pitch. Defaults to false. */ visualizePitch?: boolean } </script> <script lang="ts"> /** * Standalone zoom/compass control — the Svelte equivalent of React's * `MapNavigationControl` (react-map-gl re-export). Must be rendered inside * `<Map>` children; reads the map instance from context. For the common * case prefer `<Map navigation navigationPosition={...}>` — combine a * standalone control with `navigation={false}` to avoid duplicates. */ import { useMap } from './map-context.svelte' let { position = 'top-right', showCompass = true, showZoom = true, visualizePitch = false, }: MapNavigationControlProps = $props() const mapCtx = useMap() let control: mapboxgl.NavigationControl | null = null function teardown(map: mapboxgl.Map | null) { if (map && control) { try { map.removeControl(control) } catch { /* already removed */ } } control = null } $effect(() => { const map = mapCtx.map // Mapbox controls are immutable — recreate when options change. void [position, showCompass, showZoom, visualizePitch] if (!map) return teardown(map) control = new mapboxgl.NavigationControl({ showCompass, showZoom, visualizePitch }) map.addControl(control, position) return () => teardown(map) }) </script>src/lib/components/ui/map/MapFullscreenControl.svelte1.7 kB
<script lang="ts" module> // Value import in the module script (visible to the instance script) — both // scripts share one scope, so a second instance import would collide. import mapboxgl from 'mapbox-gl' import type { MapControlPosition } from './Map.svelte' export interface MapFullscreenControlProps { /** Control placement. Defaults to 'top-right' (react-map-gl parity). */ position?: MapControlPosition /** Element to make fullscreen. Defaults to the map container. */ container?: HTMLElement } </script> <script lang="ts"> /** * Standalone fullscreen toggle — the Svelte equivalent of React's * `MapFullscreenControl` (react-map-gl re-export). Must be rendered inside * `<Map>` children; reads the map instance from context. For the common * case prefer `<Map fullscreen fullscreenPosition={...}>` — combine a * standalone control with `fullscreen={false}` to avoid duplicates. */ import { useMap } from './map-context.svelte' let { position = 'top-right', container }: MapFullscreenControlProps = $props() const mapCtx = useMap() let control: mapboxgl.FullscreenControl | null = null function teardown(map: mapboxgl.Map | null) { if (map && control) { try { map.removeControl(control) } catch { /* already removed */ } } control = null } $effect(() => { const map = mapCtx.map // Mapbox controls are immutable — recreate when options change. void [position, container] if (!map) return teardown(map) control = new mapboxgl.FullscreenControl(container ? { container } : undefined) map.addControl(control, position) return () => teardown(map) }) </script>src/lib/components/ui/map/map-context.svelte.ts1.1 kB
import { getContext, setContext } from 'svelte' import type mapboxgl from 'mapbox-gl' export const MAP_KEY = 'uipkge-map' export const MAP_SOURCE_ID_KEY = 'uipkge-map-source-id' /** Reactive view of the enclosing `<Map>` instance. Reads track, so an * `$effect` re-runs when the map is created. */ export interface MapContext { readonly map: mapboxgl.Map | null } /** Published by `<Map>` once per instance. Must be called during init. */ export function setMapContext(ctx: MapContext): void { setContext(MAP_KEY, ctx) } /** Injects the map context published by the enclosing `<Map>`. */ export function useMap(): MapContext { const ctx = getContext<MapContext>(MAP_KEY) if (!ctx) { throw new Error('uipkge: Map* components must be rendered inside <Map>.') } return ctx } /** Published by `<MapSource>` so nested `<MapLayer>` picks up its source id. */ export function setMapSourceId(id: string): void { setContext(MAP_SOURCE_ID_KEY, id) } /** The enclosing `<MapSource>` id, if any. */ export function useMapSourceId(): string | undefined { return getContext<string | undefined>(MAP_SOURCE_ID_KEY) ?? undefined }src/lib/components/ui/map/map.variants.ts1.5 kB
import { cva, type VariantProps } from 'class-variance-authority' export type MapVariant = | 'default' | 'muted' | 'streets' | 'outdoors' | 'light' | 'dark' | 'satellite' | 'satellite-streets' | 'navigation-day' | 'navigation-night' | 'standard' export const MAPBOX_STYLES: Record<Exclude<MapVariant, 'default' | 'muted'>, string> = { streets: 'mapbox://styles/mapbox/streets-v12', outdoors: 'mapbox://styles/mapbox/outdoors-v12', light: 'mapbox://styles/mapbox/light-v11', dark: 'mapbox://styles/mapbox/dark-v11', satellite: 'mapbox://styles/mapbox/satellite-v9', 'satellite-streets': 'mapbox://styles/mapbox/satellite-streets-v12', 'navigation-day': 'mapbox://styles/mapbox/navigation-day-v1', 'navigation-night': 'mapbox://styles/mapbox/navigation-night-v1', standard: 'mapbox://styles/mapbox/standard', } export const mapVariants = cva('relative size-full overflow-hidden bg-muted', { variants: { variant: { default: '', muted: 'data-[muted=true]:grayscale-[25%]', 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 MapVariants = VariantProps<typeof mapVariants>src/lib/components/ui/map/index.ts0.8 kB
export { default as Map, type MapProps, type MapRef, type MapControlPosition } from './Map.svelte' export { mapVariants, MAPBOX_STYLES, type MapVariant, type MapVariants } from './map.variants' export { default as MapSource, type MapSourceProps } from './MapSource.svelte' export { default as MapLayer, type MapLayerProps } from './MapLayer.svelte' export { default as MapMarker, type MapMarkerProps } from './MapMarker.svelte' export { default as MapPopup, type MapPopupProps } from './MapPopup.svelte' export { default as MapNavigationControl, type MapNavigationControlProps } from './MapNavigationControl.svelte' export { default as MapFullscreenControl, type MapFullscreenControlProps } from './MapFullscreenControl.svelte' export { useMap } from './map-context.svelte'src/lib/components/ui/map/map.css1.7 kB
/** * Map chrome — re-skins Mapbox GL's controls + popups to the design-system * tokens so they sit naturally on light and dark surfaces. Global (Mapbox * renders this chrome outside the component subtree). Import once, e.g. in your * root CSS: `@import './components/ui/map/map.css';` */ .mapboxgl-ctrl-group { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); } .mapboxgl-ctrl-group button { background: transparent; } .mapboxgl-ctrl-group button + button { border-top-color: var(--border); } .mapboxgl-ctrl-group button .mapboxgl-ctrl-icon { filter: invert(var(--map-ctrl-invert, 0)) opacity(var(--opacity-muted, 0.7)); } .dark .mapboxgl-ctrl-group { background: var(--card, #18181b); border-color: var(--border, #27272a); } .dark .mapboxgl-ctrl-group button { background: transparent; } .dark .mapboxgl-ctrl-group button:hover { background: var(--muted, #27272a); } .dark .mapboxgl-ctrl-group button + button { border-top-color: var(--border, #27272a); } .dark .mapboxgl-ctrl-group button .mapboxgl-ctrl-icon { filter: invert(1) brightness(1.2); } .mapboxgl-ctrl-attrib { background: color-mix(in oklab, var(--card) 80%, transparent); color: var(--muted-foreground); } .mapboxgl-ctrl-attrib a { color: var(--muted-foreground); } .mapboxgl-popup-content { padding: var(--spacing-2) var(--spacing-3); background: var(--popover); color: var(--popover-foreground); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-md); } .mapboxgl-popup-close-button { color: var(--muted-foreground); } .mapboxgl-popup-tip { border-top-color: var(--popover); border-bottom-color: var(--popover); }
Raw manifest:https://uipkge.dev/r/svelte/map-turn-by-turn-hud.json