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
// 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.
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
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).
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
Geolocation.watch(callback: (pos: GeoPosition) => void, options?: GeoOptions): Promise<GeoWatch>
watch.stop(): void // GeoWatch: ends this subscriptionTrack 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.
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.
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
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.
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 |
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
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".
// 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 —
setUserLocationdraws the position on a map - Permissions —
permissions.query / request('location'),openSettings() - App —
pause/resume, when the app leaves the foreground - Conventions — host gating —
isSupportedguards (Physics.supported,QRScanner.isSupported)