Geolocation

The user's location — latitude, longitude and how accurate they are — once (Geolocation.getCurrent) or each time it changes (Geolocation.watch), from the device's GPS, Wi-Fi and cell towers. It draws nothing: to show the position on a map, pass [pos.longitude, pos.latitude] to a MapView's setUserLocation (map-view.md).

  • get the user's current GPS location once
  • track the user's location as they move, and stop when the screen closes
  • get a precise GPS fix, or give up when none arrives in time
  • ask for the location permission at the right moment and handle a refusal
  • turn location on for the app and check the device has it (Geolocation.isSupported)
  • measure the distance from the user to a place

Platforms: iOS, Android, the web and the web player; not on desktop or in headless tests — check Geolocation.isSupported.

At a glance

TypeScript
// app.json: "plugins": { "geolocation": "*" }
if (Geolocation.isSupported) {
  Geolocation.getCurrent()                                     // one fix; the OS asks for the permission the first time
    .then(pos => console.log(pos.latitude, pos.longitude, '±' + pos.accuracy + ' m'))
    .catch(e => console.log('no fix:', e.message))             // "denied" | "unavailable" | "timeout"

  Geolocation.watch(pos => console.log('now at', pos.latitude, pos.longitude))   // every new fix…
    .then(watch => setTimeout(() => watch.stop(), 60000))      // …for one minute
    .catch(e => console.log('no watch:', e.message))
} else {
  console.log('no location on this host')
}

Add location to the app: app.json and isSupported

Location comes from the geolocation plugin: list it in the project's app.json — "plugins": { "geolocation": "*" } (a project without an app.json can start one with just that key) — and guard the feature with Geolocation.isSupported, which is false on desktop, in headless tests and wherever the app does not list the plugin. There, getCurrent and watch reject at once with an error that says so, before any permission prompt.

The list is one for every platform (the bundle's // plugins: header): a plugin it does not name is not there — in a shell built from the project, on the web and in the web player alike — and lecodes dev / lecodes compile warn about one the app uses and does not list. The le.codes viewer app has the plugin whatever the list says.

TypeScript
const near = signal('')

const findNearby = () => Geolocation.getCurrent()
  .then(pos => { near.value = `Stores near ${pos.latitude.toFixed(3)}, ${pos.longitude.toFixed(3)}` })
  .catch(e => { near.value = 'No location: ' + e.message })

UIScreen(
  Geolocation.isSupported
    ? UIButton(UIText('Stores near me')).onClick(findNearby)
    : UIText('Location is not available here: type a city instead'),
  UIText(() => near.value),
).style({ p: 24, gap: 12 }).open()

Current location (GPS): Geolocation.getCurrent

TypeScript
Geolocation.getCurrent(options?: GeoOptions): Promise<GeoPosition>

Get the user's current location once — latitude, longitude and an accuracy radius in meters (GeoPosition). The first call brings the OS's permission dialog (the browser's on the web), so make it from a tap where the user expects the question. The promise rejects with "denied", "unavailable" or "timeout" as e.message (permission and errors).

TypeScript
const where = signal('Tap to find your location')

const locate = async () => {
  where.value = 'Locating…'
  try {
    const pos = await Geolocation.getCurrent()                  // the OS asks for the permission the first time
    where.value = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)} (±${Math.round(pos.accuracy)} m)`
  } catch (e) {
    where.value = 'No location: ' + e.message                  // "denied" | "unavailable" | "timeout"
  }
}

UIScreen(
  UIText(() => where.value).style({ fontSize: 18 }),
  UIButton(UIText('Where am I?')).onClick(locate),
).style({ p: 24, gap: 16 }).open()

Track the location as the user moves: Geolocation.watch

TypeScript
Geolocation.watch(callback: (pos: GeoPosition) => void, options?: GeoOptions): Promise<GeoWatch>
watch.stop(): void              // GeoWatch: ends this subscription

Track the user's location continuously: callback gets each new fix as the platform reports it (on Android at most one a second). The promise resolves once the watch is live — permission granted, provider started — so a refusal is a rejection, never a callback that silently stays quiet; after that, a lost signal (a tunnel) only pauses the fixes.

Start the watch in the screen's onOpen and stop() it in onClose: a running watch keeps the location hardware and the OS's location indicator on. onClose also fires when another screen is pushed over this one, onOpen when it comes back.

TypeScript
const where = signal('Locating…')
let watch: GeoWatch | null = null
let shown = false

const screen = UIScreen(UIText(() => where.value).style({ fontSize: 20 }))
  .onOpen(() => {                                            // the first open, and back from a pushed screen
    shown = true
    Geolocation.watch(pos => { where.value = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}` })
      .then(w => { if (shown && !watch) watch = w; else w.stop() })   // closed while it started: stop it
      .catch(e => { where.value = 'No location: ' + e.message })
  })
  .onClose(() => { shown = false; watch?.stop(); watch = null })     // the location hardware goes off

screen.open()

Every watch() shares one watch of the OS: two subscriptions cost one GPS session, the options of the one that started it apply to both, and the last stop() turns it off.

Note

there is no background location — the permission is while-in-use and the app's code pauses when it leaves the foreground, so fixes reach the callback only while the app is open.

Precise GPS fix and a time limit: GeoOptions

TypeScript
interface GeoOptions {
  highAccuracy?: boolean        // default false
  timeout?: number              // ms; getCurrent only
}

Ask for a precise GPS fix with highAccuracy: true, and bound the wait with timeout. The default is the quick, coarser fix (iOS aims at about 100 m, Android takes its fused or network provider, the browser its default); true asks for the best the device has (iOS's best accuracy, Android's GPS provider, the browser's high-accuracy mode) — a slower first fix, more battery, and indoors maybe none at all. timeout makes getCurrent reject with "timeout" after that many milliseconds; without it the call waits as long as the platform takes. watch reads highAccuracy only, and while a watch runs a second one's options are ignored.

TypeScript
const main = async () => {
  try {
    const pos = await Geolocation.getCurrent({ highAccuracy: true, timeout: 15000 })   // GPS, at most 15 s
    console.log(`at ${pos.latitude}, ${pos.longitude} ±${pos.accuracy} m`)
  } catch (e) {
    if (e.message === 'timeout') console.log('no GPS fix in 15 s — try again outdoors')
    else console.log('no location:', e.message)
  }
}
main()

Location permission and errors

The OS asks for the location permission on the first getCurrent or watch call. The permission is while-in-use, and the plugin brings the platform declarations it needs (the iOS usage text, the Android manifest entries). To ask before the first call, or to read the state without a dialog, use permissions.request('location') / permissions.query('location'); after a refusal, permissions.openSettings() opens the app's page of the system settings (a no-op in a browser) — permissions.md. The calls reject with a code as e.message:

e.message when
"denied" the user refused, now or earlier (iOS never asks again); in a browser, also a page on plain http
"unavailable" no fix: no location provider is on, or the OS could not get one
"timeout" getCurrent only: no fix within options.timeout ms
TypeScript
const note = signal('')
const denied = signal(false)

const shareLocation = () => Geolocation.getCurrent()       // the OS dialog comes with the first call
  .then(pos => { denied.value = false; note.value = `At ${pos.latitude.toFixed(4)}, ${pos.longitude.toFixed(4)}` })
  .catch(e => {
    denied.value = e.message === 'denied'                    // refused now, or earlier
    note.value = denied.value ? 'Location is off for this app' : 'No location: ' + e.message
  })

UIScreen(
  UIButton(UIText('Share my location')).onClick(shareLocation),
  UIText(() => note.value),
  UIButton(UIText('Open Settings')).onClick(() => permissions.openSettings())
    .style({ display: () => (denied.value ? 'flex' : 'none') }),
).style({ p: 24, gap: 12 }).open()

What a fix holds: GeoPosition

TypeScript
interface GeoPosition {
  latitude: number; longitude: number   // degrees
  accuracy: number              // horizontal radius, meters — smaller is better
  altitude: number | null       // meters, null when unknown
  heading: number | null        // direction of travel, degrees clockwise from north; null when unknown
  speed: number | null          // m/s, null when unknown
  timestamp: number             // when the fix was taken, ms since the epoch
}

A fix is plain data: keep it, compare it, send it to your server. heading and speed describe movement — read heading only while speed is above zero. The age of a fix is Date.now() - pos.timestamp.

Distance from the user to a place

The SDK has no distance helper: compute the distance between a fix and a place (or two fixes) along the Earth's surface with the haversine formula — within about 0.5 %, plenty for "2.3 km away".

TypeScript
// Distance in meters between two points in degrees, along the Earth's surface (haversine).
const distanceM = (lat1: number, lon1: number, lat2: number, lon2: number): number => {
  const rad = Math.PI / 180
  const dLat = (lat2 - lat1) * rad, dLon = (lon2 - lon1) * rad
  const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLon / 2) ** 2
  return 2 * 6371000 * Math.asin(Math.sqrt(a))
}

const store = { latitude: 55.7539, longitude: 37.6208 }

Geolocation.getCurrent()
  .then(pos => {
    const km = distanceM(pos.latitude, pos.longitude, store.latitude, store.longitude) / 1000
    console.log(`the store is ${km.toFixed(1)} km away`)
  })
  .catch(e => console.log('no location:', e.message))

See also

  • MapView — setUserLocation draws the position on a map
  • Permissions — permissions.query / request('location'), openSettings()
  • App — pause / resume, when the app leaves the foreground
  • Conventions — host gating — isSupported guards (Physics.supported, QRScanner.isSupported)