Location

Locate-me, delivery, and routing demos.

Live demo

01

Click the locate button (top-right) to find your current position. Browser permission required โ€” needs HTTPS or localhost.

Loading mapโ€ฆ
Coordinates
โ€” click locate to begin
Accuracy
โ€”

Recipes

  • "Find me on a map" page. Default MapLocateMe with showAccuracyCircle. Best for one-shot lookups (delivery address, "stores near me").
  • Live navigation. Add watchPosition and keep a state strip showing samples + last-update time. Track the first 10 samples then debounce โ€” handheld GPS jitters fast indoors.
  • GPS confidence visualisation. Set enableHighAccuracy={false} on a city-level zoom to get a visibly large accuracy circle. Helpful for explaining "GPS works, but it isn't perfect" in an onboarding flow.
  • Permission-denied state. Listen for onError with type PERMISSION_DENIED and render a non-blocking explainer + retry button. Don't auto-prompt โ€” browsers throttle repeated requests.

Implementation

02
MapLocateMe.svelte
<script lang="ts">
  import MapLocateMe from '$lib/components/MapLocateMe.svelte';
</script>
โ€‹
<MapLocateMe onLocate={(coords) => console.log('user is at', coords)} />

MapLocateMe wraps the browser Geolocation API with permission prompts, an accuracy circle, and an optional watchPosition mode โ€” coordinates and altitude come back through the onLocate callback. MapRouting takes two LatLng waypoints, calls the public OSRM demo endpoint, and renders a polyline plus distance, duration, and turn-by-turn steps. Both components are mounted with `bind:` props so the parent always sees the latest origin / destination / location.

Logic explainer

03

What Does It Do? (Plain English)

A map with a single button labelled with a crosshair icon. Tap it, the browser asks for permission to read your location, and โ€” if you grant it โ€” the map pans and zooms to where you are, drops a pulsing blue dot, and draws a translucent circle showing how confident the GPS is in that position. Optionally, the dot keeps following you as you move.

Think of it as the "you are here" arrow on a shopping centre map, except the arrow knows where you actually are because the browser asks the device for GPS, Wi-Fi, or cell-tower triangulation results. The accuracy circle is honesty in cartographic form: a tight 5-metre ring outdoors with GPS, a fuzzy 500-metre ring indoors on Wi-Fi alone.

How It Works (Pseudo-Code)

state:
  isLocating       = false
  hasLocation      = false
  locationError    = null            // user-facing message
  currentLocation  = null            // GeolocationResult on success
  watchId          = undefined       // for clearWatch()
  isGeolocationSupported = $derived(isBrowser && 'geolocation' in navigator)

mount ($effect):
  1. Dynamic-import 'leaflet' (SSR-safe)
  2. Read prefers-reduced-motion to gate Leaflet animations
  3. Create map, attach OSM tiles, add zoom control bottom-right

cleanup:
  if watchId !== undefined: navigator.geolocation.clearWatch(watchId)
  mapInstance.remove()

locateMe() (called by button click or external ref):
  1. if !isGeolocationSupported:
       error('NOT_SUPPORTED'); return
  2. isLocating = true; locationError = null
  3. options = { enableHighAccuracy, timeout, maximumAge }
  4. if watchPosition:
       watchId = navigator.geolocation.watchPosition(success, error, options)
     else:
       navigator.geolocation.getCurrentPosition(success, error, options)

handlePositionSuccess(position):
  1. Build GeolocationResult { position, accuracy, altitude?, heading?, speed?, timestamp }
  2. currentLocation = result; hasLocation = true; isLocating = false
  3. if showAccuracyCircle:
       create or move L.circle(latLng, radius=accuracy)
  4. create or move L.marker with custom divIcon { pulse-ring, pulse-core }
  5. map.setView(latLng, locateZoom)
  6. onLocate?.(result)

handlePositionError(error):
  1. errorType = mapErrorCode(error.code)        // 1โ†’DENIED, 2โ†’UNAVAILABLE, 3โ†’TIMEOUT
  2. message  = getErrorMessage(errorType)        // human-readable
  3. isLocating = false; locationError = message
  4. onError?.(errorType, message)

clearLocation() (exported):
  remove marker + circle; hasLocation = false; stopWatching()

The Core Concept: Accuracy Circles and Confidence

The browser's Geolocation API returns a coords.accuracy value in metres. This is not an "average error" or a "ยฑ reading" โ€” it is the radius of a 95% confidence circle. The actual position is, with 95% probability, somewhere inside that circle. So accuracy: 12 means "we are 95% sure you are within 12 metres of the centre point we returned"; accuracy: 1500 means "we know you are roughly in this neighbourhood, but we cannot pin you to a specific street".

The component renders this honestly: a translucent blue L.circle with radius: result.accuracy (Leaflet circles take radius in metres at the equator and adjust for latitude internally). The pulsing dot in the centre is the point estimate; the circle is the uncertainty. Together they communicate "here, plus or minus this much" without forcing the user to read a number.

Where does the accuracy come from? The browser combines whatever signals are available:

  • GPS โ€” sub-10 m outdoors with clear sky, useless indoors.
  • Wi-Fi triangulation โ€” Google/Apple maintain databases of Wi-Fi BSSID โ†’ physical location. 20โ€“100 m typical, indoors-friendly.
  • Cell tower triangulation โ€” 500โ€“5000 m, last-resort fallback.
  • IP geolocation โ€” city-level, only when nothing better is available.

The enableHighAccuracy: true flag tells the browser to prefer GPS, which costs battery but yields the tightest circles. On desktops without a GPS chip, the flag has no effect โ€” the browser just uses whatever it has.

Performance: Watch vs. One-Shot Geolocation

The component supports two modes via the watchPosition prop:

One-shot (watchPosition: false, default):

  • Calls navigator.geolocation.getCurrentPosition(...) once.
  • Returns a single position; no further updates.
  • Cheap on battery โ€” the GPS chip can power back down immediately.
  • Right for "find my location" buttons that fire and forget.

Continuous (watchPosition: true):

  • Calls navigator.geolocation.watchPosition(...) and stores the returned watchId.
  • The browser fires the success callback whenever the position changes meaningfully (the threshold is browser-defined, typically a few metres of movement or a confidence improvement).
  • The marker and accuracy circle move smoothly as new readings arrive.
  • The GPS chip stays warm โ€” battery cost is real on phones, ~5โ€“10% per hour with high accuracy.
  • Cleanup is critical: the $effect cleanup function calls navigator.geolocation.clearWatch(watchId) so a stale watcher does not survive component unmount.

For battery-conscious continuous tracking, raise maximumAge so the browser can return cached positions when fresh GPS is not available, and lower enableHighAccuracy to false so it can fall back to network-based positioning.

Accessibility Deep-Dive: Permission UX

Geolocation permission prompts are notoriously hostile to users โ€” they appear in browser chrome (out of the page's control), dismissing them silently denies the request, and "Block" persists for the entire origin until the user manually unblocks the site in browser settings. The component cannot improve the prompt itself, but it does its best around it:

  • Explicit user gesture. The browser only allows the permission prompt to appear in response to a click โ€” it will not trigger from onMount or a setTimeout. The component honours this by tying locateMe() to a button click. (Programmatic locateMe calls from a parent will still work if they are inside an event handler, e.g. on a wrapper button.)
  • All error states are spoken. locationError renders in a [role="alert"] div, which screen readers announce immediately. The error messages are written for humans, not developers: "Location access was denied. Please enable location permissions in your browser settings." rather than "PERMISSION_DENIED".
  • Loading state is announced. The button's aria-label flips between "Find my location", "Finding your locationโ€ฆ", and "Re-center on your location" so AT users know what state the request is in.
  • The dismiss button has an aria-label. Errors can be cleared without a mouse.
  • Keyboard fully supported. Tab to the button, Enter/Space to trigger; Escape on the popup closes it (Leaflet default).

The one thing the component cannot do is detect whether the user has previously blocked permission for this origin. The Permissions API (navigator.permissions.query({name: 'geolocation'})) can tell you if the state is 'denied', but support is uneven and the component treats every request as fresh โ€” the browser will silently re-deny without re-prompting if it has been blocked.

State Flow Diagram

                  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                  โ”‚   IDLE               โ”‚  isLocating=false
                  โ”‚   no marker, no ring โ”‚  hasLocation=false
                  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                             โ”‚ button click โ†’ locateMe()
                             โ–ผ
                  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                  โ”‚   PERMISSION REQUESTEDโ”‚  isLocating=true
                  โ”‚   browser prompt up   โ”‚  spinner visible
                  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                             โ”‚
        โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
        โ”‚ granted            โ”‚ denied             โ”‚ timeout / unavailable
        โ–ผ                    โ–ผ                    โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ POSITION ACQUIREDโ”‚  โ”‚ ERROR: DENIED    โ”‚  โ”‚ ERROR: TIMEOUT/  โ”‚
โ”‚ marker + ring    โ”‚  โ”‚ alert banner     โ”‚  โ”‚ UNAVAILABLE      โ”‚
โ”‚ map.setView()    โ”‚  โ”‚ onError fired    โ”‚  โ”‚ alert banner     โ”‚
โ”‚ onLocate fired   โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
       โ”‚ if watchPosition: position changes โ”€โ”€โ•ฎ
       โ”‚                                       โ–ผ
       โ”‚                              โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
       โ”‚                              โ”‚ TRACKING         โ”‚
       โ”‚                              โ”‚ marker + ring    โ”‚
       โ”‚                              โ”‚   move on update โ”‚
       โ”‚                              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
       โ”‚ clearLocation() (exported)
       โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   IDLE (reset)   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Props Reference

Prop Type Default Description
center LatLng DEFAULT_MAP_CENTER Initial map centre before location is found.
zoom number 13 Initial zoom level.
height number 400 Map container height in pixels.
locateZoom number 16 Zoom level applied when a location is acquired.
showAccuracyCircle boolean true Render the translucent blue confidence circle.
enableHighAccuracy boolean true Hint to the browser to prefer GPS. Costs battery on mobile.
timeout number 10000 Milliseconds to wait before firing a TIMEOUT error.
maximumAge number 0 Maximum age (ms) of an acceptable cached position. 0 forces fresh.
watchPosition boolean false If true, continuously track position via watchPosition API.
buttonPosition 'topleft' | 'topright' | 'bottomleft' | 'bottomright' 'topright' Corner placement of the locate button.
onLocate (result: GeolocationResult) => void undefined Fires on every successful position read (once for one-shot, repeatedly for watch).
onError (error: GeolocationErrorType, message: string) => void undefined Fires on permission denial, timeout, or unavailability.
class string '' Extra classes for the container.

The component also exports locateMe(), stopWatching(), and clearLocation() so a parent can drive it imperatively via bind:this.

Edge Cases

Situation Behaviour
User denies permission onError('PERMISSION_DENIED', โ€ฆ) fires; an alert banner appears with instructions to re-enable in browser settings.
User dismisses the prompt without choosing Most browsers treat dismissal as denial after a beat; the TIMEOUT error fires after the configured timeout.
Browser does not support geolocation (very old) isGeolocationSupported is false; clicking the button immediately fires onError('NOT_SUPPORTED', โ€ฆ) without prompting.
Served over plain HTTP (not localhost) All modern browsers refuse geolocation on insecure origins; error.code === 1 (PERMISSION_DENIED) fires immediately. Deploy on HTTPS.
Indoors with no GPS The browser falls back to Wi-Fi/cell positioning; accuracy circle widens dramatically (often 500+ m). The component renders this honestly rather than hiding it.
watchPosition: true and component unmounts The $effect cleanup calls clearWatch(watchId) so the GPS chip powers down and the callback is detached.
User scrolls / pans away after location is acquired The marker stays at its real position; the map view does not auto-recentre on subsequent updates unless watchPosition is true.
Multiple rapid clicks on the button The button is disabled while isLocating; redundant clicks do nothing.
Position update arrives after clearLocation() The watcher was cleared in stopWatching(), so no stale callback fires.
prefers-reduced-motion: reduce The pulsing-dot animation is disabled via CSS @media; Leaflet's pan-to animation is disabled at map construction.

Dependencies

  • leaflet (~150 KB gzip) โ€” Same justification as MapLive: industry-standard, would take 100+ hours to replicate. Dynamic-imported, SSR-safe.
  • Browser Geolocation API โ€” Native, no external service. Backed by GPS, Wi-Fi, cell, and (rarely) IP-based positioning depending on hardware.
  • Leaflet CSS โ€” Loaded globally; without it the location marker's custom divIcon CSS still renders correctly because it is scoped, but tile controls are unstyled.
  • OpenStreetMap tiles โ€” Free public tile server; for production traffic use a paid provider as per OSM's tile usage policy.

File Structure

src/lib/components/MapLocateMe.svelte     # locate-me + accuracy circle implementation
src/lib/components/MapRouting.svelte      # related: OSRM-driven route planning
src/lib/components/Location.md            # this file (rendered inside ComponentPageShell)
src/routes/location/+page.svelte          # demo page (locate-me + delivery + routing)
src/lib/types.ts                          # MapLocateMeProps, GeolocationResult,
                                          # GeolocationErrorType, RouteResult, RouteWaypoint
src/lib/constants.ts                      # DEFAULT_MAP_CENTER
src/lib/mapUtils.ts                       # calculateMapBounds โ€” fit-to-points helper

API

04
FeatureMapLocateMeMapRouting
Find user locationYes (Geolocation API)โ€”
Accuracy circleYesโ€”
Real-time trackingOptional (watchPosition)โ€”
MarkersSingle (you)Two (A / B)
Route polylineโ€”Yes
Turn-by-turn stepsโ€”Yes (OSRM)
External APIBrowser onlyOSRM demo endpoint
HTTPS / localhost requiredYesNo