From 29daaccb14c81cd5f5382f0c3e7d1002fba17cdd Mon Sep 17 00:00:00 2001 From: Walter Boring Date: Wed, 9 Sep 2026 14:07:44 -0400 Subject: [PATCH] feat: add browser My Location control (one-shot + hold-to-follow) Adds a location-bar pin that flies the camera to navigator.geolocation on click and toggles live follow on long-press, without requiring GOOGLE_MAPS_API_KEY. Co-authored-by: Cursor --- index.html | 3 + src/browserGeolocation.js | 251 ++++++++++++++++++++++++++++++++ src/browserGeolocation.test.mjs | 145 ++++++++++++++++++ src/ui.js | 218 ++++++++++++++++++++++++++- style.css | 11 ++ 5 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 src/browserGeolocation.js create mode 100644 src/browserGeolocation.test.mjs diff --git a/index.html b/index.html index e8b0b8d..f8cb50c 100644 --- a/index.html +++ b/index.html @@ -562,6 +562,9 @@
+ diff --git a/src/browserGeolocation.js b/src/browserGeolocation.js new file mode 100644 index 0000000..a4a0f03 --- /dev/null +++ b/src/browserGeolocation.js @@ -0,0 +1,251 @@ +/** + * Browser Geolocation helpers for one-shot fly-to and optional live follow. + * Uses navigator.geolocation only — no Google / Cesium ion keys required. + */ + +export const GEOLOCATION_UNAVAILABLE = 'unavailable'; +export const GEOLOCATION_DENIED = 'denied'; +export const GEOLOCATION_TIMEOUT = 'timeout'; +export const GEOLOCATION_POSITION_UNAVAILABLE = 'position-unavailable'; +export const GEOLOCATION_UNKNOWN = 'unknown'; + +const DEFAULT_POSITION_OPTIONS = Object.freeze({ + enableHighAccuracy: true, + timeout: 15000, + maximumAge: 10000, +}); + +/** + * @returns {boolean} + */ +export function isGeolocationAvailable(geo = globalThis.navigator?.geolocation) { + return Boolean(geo && typeof geo.getCurrentPosition === 'function'); +} + +/** + * Map a GeolocationPositionError (or synthetic) to a stable code. + * @param {GeolocationPositionError|{code?:number,message?:string}|null} err + * @returns {string} + */ +export function classifyGeolocationError(err) { + if (!err) return GEOLOCATION_UNKNOWN; + const code = Number(err.code); + if (code === 1) return GEOLOCATION_DENIED; + if (code === 2) return GEOLOCATION_POSITION_UNAVAILABLE; + if (code === 3) return GEOLOCATION_TIMEOUT; + const message = String(err.message || '').toLowerCase(); + if (message.includes('denied') || message.includes('permission')) return GEOLOCATION_DENIED; + if (message.includes('timeout')) return GEOLOCATION_TIMEOUT; + if (message.includes('unavailable')) return GEOLOCATION_POSITION_UNAVAILABLE; + return GEOLOCATION_UNKNOWN; +} + +/** + * User-facing toast text for a classified error. + * @param {string} code + * @returns {string} + */ +export function geolocationErrorMessage(code) { + switch (code) { + case GEOLOCATION_UNAVAILABLE: + return 'Location not supported in this browser'; + case GEOLOCATION_DENIED: + return 'Location permission denied'; + case GEOLOCATION_TIMEOUT: + return 'Location request timed out'; + case GEOLOCATION_POSITION_UNAVAILABLE: + return 'Location unavailable'; + default: + return 'Could not get your location'; + } +} + +/** + * Normalize a GeolocationPosition into { lat, lon, accuracy, heading, speed, timestamp }. + * @param {GeolocationPosition} position + * @returns {{lat:number,lon:number,accuracy:number|null,heading:number|null,speed:number|null,timestamp:number}} + */ +export function normalizeGeolocationPosition(position) { + const coords = position?.coords; + if (!coords || !Number.isFinite(coords.latitude) || !Number.isFinite(coords.longitude)) { + throw new Error('Invalid geolocation position'); + } + return { + lat: coords.latitude, + lon: coords.longitude, + accuracy: Number.isFinite(coords.accuracy) ? coords.accuracy : null, + heading: Number.isFinite(coords.heading) ? coords.heading : null, + speed: Number.isFinite(coords.speed) ? coords.speed : null, + timestamp: Number.isFinite(position.timestamp) ? position.timestamp : Date.now(), + }; +} + +/** + * One-shot current position. + * @param {object} [options] + * @param {Geolocation} [options.geo] + * @param {PositionOptions} [options.positionOptions] + * @returns {Promise<{lat:number,lon:number,accuracy:number|null,heading:number|null,speed:number|null,timestamp:number}>} + */ +export function getCurrentBrowserPosition({ + geo = globalThis.navigator?.geolocation, + positionOptions = DEFAULT_POSITION_OPTIONS, +} = {}) { + if (!isGeolocationAvailable(geo)) { + const err = new Error(geolocationErrorMessage(GEOLOCATION_UNAVAILABLE)); + err.code = GEOLOCATION_UNAVAILABLE; + return Promise.reject(err); + } + return new Promise((resolve, reject) => { + geo.getCurrentPosition( + (position) => { + try { + resolve(normalizeGeolocationPosition(position)); + } catch (err) { + const wrapped = new Error(geolocationErrorMessage(GEOLOCATION_UNKNOWN)); + wrapped.code = GEOLOCATION_UNKNOWN; + wrapped.cause = err; + reject(wrapped); + } + }, + (error) => { + const code = classifyGeolocationError(error); + const err = new Error(geolocationErrorMessage(code)); + err.code = code; + err.cause = error; + reject(err); + }, + positionOptions, + ); + }); +} + +/** + * Live watch. Caller must stop() when done. + * @param {object} options + * @param {(fix:{lat:number,lon:number,accuracy:number|null,heading:number|null,speed:number|null,timestamp:number}) => void} options.onUpdate + * @param {(err:Error) => void} [options.onError] + * @param {Geolocation} [options.geo] + * @param {PositionOptions} [options.positionOptions] + * @returns {{stop:() => void, watchId:number|null}} + */ +export function watchBrowserPosition({ + onUpdate, + onError = null, + geo = globalThis.navigator?.geolocation, + positionOptions = DEFAULT_POSITION_OPTIONS, +} = {}) { + if (typeof onUpdate !== 'function') { + throw new Error('watchBrowserPosition requires onUpdate'); + } + if (!isGeolocationAvailable(geo) || typeof geo.watchPosition !== 'function') { + const err = new Error(geolocationErrorMessage(GEOLOCATION_UNAVAILABLE)); + err.code = GEOLOCATION_UNAVAILABLE; + onError?.(err); + return { stop() {}, watchId: null }; + } + + let stopped = false; + const watchId = geo.watchPosition( + (position) => { + if (stopped) return; + try { + onUpdate(normalizeGeolocationPosition(position)); + } catch (err) { + const wrapped = new Error(geolocationErrorMessage(GEOLOCATION_UNKNOWN)); + wrapped.code = GEOLOCATION_UNKNOWN; + wrapped.cause = err; + onError?.(wrapped); + } + }, + (error) => { + if (stopped) return; + const code = classifyGeolocationError(error); + const err = new Error(geolocationErrorMessage(code)); + err.code = code; + err.cause = error; + onError?.(err); + }, + positionOptions, + ); + + return { + watchId, + stop() { + if (stopped) return; + stopped = true; + try { geo.clearWatch?.(watchId); } catch { /* no-op */ } + }, + }; +} + +/** + * Detect click vs long-press for the My Location control. + * Short release (< holdMs) → 'click'. Held ≥ holdMs → 'hold' (fires once while pressed). + * @param {object} options + * @param {number} [options.holdMs=550] + * @param {() => void} options.onClick + * @param {() => void} options.onHold + * @returns {{attach:(el:HTMLElement)=>() => void}} + */ +export function createPressGesture({ holdMs = 550, onClick, onHold } = {}) { + if (typeof onClick !== 'function' || typeof onHold !== 'function') { + throw new Error('createPressGesture requires onClick and onHold'); + } + return { + attach(el) { + if (!el?.addEventListener) return () => {}; + let timer = null; + let holdFired = false; + let pointerId = null; + + const clear = () => { + if (timer != null) { + clearTimeout(timer); + timer = null; + } + }; + + const onDown = (event) => { + if (event.button != null && event.button !== 0) return; + holdFired = false; + pointerId = event.pointerId ?? 'mouse'; + clear(); + timer = setTimeout(() => { + timer = null; + holdFired = true; + onHold(); + }, holdMs); + }; + + const onUp = (event) => { + if (pointerId != null && event.pointerId != null && event.pointerId !== pointerId) return; + const wasHold = holdFired; + clear(); + pointerId = null; + if (!wasHold) onClick(); + holdFired = false; + }; + + const onCancel = () => { + clear(); + pointerId = null; + holdFired = false; + }; + + el.addEventListener('pointerdown', onDown); + el.addEventListener('pointerup', onUp); + el.addEventListener('pointerleave', onCancel); + el.addEventListener('pointercancel', onCancel); + el.addEventListener('contextmenu', (e) => e.preventDefault()); + + return () => { + clear(); + el.removeEventListener('pointerdown', onDown); + el.removeEventListener('pointerup', onUp); + el.removeEventListener('pointerleave', onCancel); + el.removeEventListener('pointercancel', onCancel); + }; + }, + }; +} diff --git a/src/browserGeolocation.test.mjs b/src/browserGeolocation.test.mjs new file mode 100644 index 0000000..b6efb24 --- /dev/null +++ b/src/browserGeolocation.test.mjs @@ -0,0 +1,145 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + GEOLOCATION_DENIED, + GEOLOCATION_TIMEOUT, + GEOLOCATION_UNAVAILABLE, + classifyGeolocationError, + createPressGesture, + geolocationErrorMessage, + getCurrentBrowserPosition, + isGeolocationAvailable, + normalizeGeolocationPosition, + watchBrowserPosition, +} from './browserGeolocation.js'; + +test('isGeolocationAvailable requires getCurrentPosition', () => { + assert.equal(isGeolocationAvailable(null), false); + assert.equal(isGeolocationAvailable({}), false); + assert.equal(isGeolocationAvailable({ getCurrentPosition() {} }), true); +}); + +test('classifyGeolocationError maps standard codes', () => { + assert.equal(classifyGeolocationError({ code: 1 }), GEOLOCATION_DENIED); + assert.equal(classifyGeolocationError({ code: 3 }), GEOLOCATION_TIMEOUT); + assert.equal(classifyGeolocationError({ message: 'User denied Geolocation' }), GEOLOCATION_DENIED); +}); + +test('normalizeGeolocationPosition extracts lat/lon', () => { + const fix = normalizeGeolocationPosition({ + timestamp: 1000, + coords: { + latitude: 30.2672, + longitude: -97.7431, + accuracy: 12, + heading: null, + speed: null, + }, + }); + assert.equal(fix.lat, 30.2672); + assert.equal(fix.lon, -97.7431); + assert.equal(fix.accuracy, 12); +}); + +test('getCurrentBrowserPosition resolves a fix', async () => { + const geo = { + getCurrentPosition(success) { + success({ + timestamp: 42, + coords: { latitude: 1, longitude: 2, accuracy: 5, heading: NaN, speed: NaN }, + }); + }, + }; + const fix = await getCurrentBrowserPosition({ geo }); + assert.deepEqual(fix, { + lat: 1, + lon: 2, + accuracy: 5, + heading: null, + speed: null, + timestamp: 42, + }); +}); + +test('getCurrentBrowserPosition rejects when unavailable', async () => { + await assert.rejects( + () => getCurrentBrowserPosition({ geo: null }), + (err) => { + assert.equal(err.code, GEOLOCATION_UNAVAILABLE); + assert.equal(err.message, geolocationErrorMessage(GEOLOCATION_UNAVAILABLE)); + return true; + }, + ); +}); + +test('getCurrentBrowserPosition rejects permission denied', async () => { + const geo = { + getCurrentPosition(_success, error) { + error({ code: 1, message: 'denied' }); + }, + }; + await assert.rejects( + () => getCurrentBrowserPosition({ geo }), + (err) => err.code === GEOLOCATION_DENIED, + ); +}); + +test('watchBrowserPosition streams updates and stop clears watch', () => { + const updates = []; + let cleared = null; + const geo = { + getCurrentPosition() {}, + watchPosition(success) { + success({ + timestamp: 1, + coords: { latitude: 10, longitude: 20, accuracy: 3, heading: 90, speed: 1 }, + }); + return 77; + }, + clearWatch(id) { cleared = id; }, + }; + const handle = watchBrowserPosition({ + geo, + onUpdate: (fix) => updates.push(fix), + }); + assert.equal(handle.watchId, 77); + assert.equal(updates.length, 1); + assert.equal(updates[0].lat, 10); + handle.stop(); + assert.equal(cleared, 77); + handle.stop(); // idempotent + assert.equal(cleared, 77); +}); + +test('createPressGesture fires click on short press and hold on long press', async () => { + const clicks = []; + const holds = []; + const listeners = new Map(); + const el = { + addEventListener(type, fn) { + listeners.set(type, fn); + }, + removeEventListener(type) { + listeners.delete(type); + }, + }; + const gesture = createPressGesture({ + holdMs: 40, + onClick: () => clicks.push('click'), + onHold: () => holds.push('hold'), + }); + const detach = gesture.attach(el); + + listeners.get('pointerdown')({ button: 0, pointerId: 1 }); + listeners.get('pointerup')({ pointerId: 1 }); + assert.deepEqual(clicks, ['click']); + assert.deepEqual(holds, []); + + listeners.get('pointerdown')({ button: 0, pointerId: 2 }); + await new Promise((r) => setTimeout(r, 60)); + assert.deepEqual(holds, ['hold']); + listeners.get('pointerup')({ pointerId: 2 }); + assert.deepEqual(clicks, ['click']); // no second click after hold + + detach(); +}); diff --git a/src/ui.js b/src/ui.js index 668e752..251dc09 100644 --- a/src/ui.js +++ b/src/ui.js @@ -12,7 +12,13 @@ import { clampBloomIntensity, decodeBloomIntensity, } from './bloom.js'; -import { LOCATIONS, CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, flyToPOI, searchAndFlyTo } from './locations.js'; +import { LOCATIONS, CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToLandmark, flyToPresetLocation, flyToPOI, searchAndFlyTo } from './locations.js'; +import { + createPressGesture, + getCurrentBrowserPosition, + isGeolocationAvailable, + watchBrowserPosition, +} from './browserGeolocation.js'; import { locationMiniStatus } from './locationStatus.js'; import { interruptCameraMotion } from './cameraVerbs.js'; import { @@ -2368,6 +2374,12 @@ export class StyleManager { this._toast = document.getElementById('toast'); this._locationSearch = document.getElementById('location-search'); this._searchToggle = document.getElementById('search-toggle'); + this._myLocationBtn = document.getElementById('my-location-btn'); + this._myLocationFollow = false; + this._myLocationWatch = null; + this._myLocationEntity = null; + this._myLocationDetachGesture = null; + this._myLocationUserInterruptHandler = null; this._locationPills = document.getElementById('location-pills'); this._poiRow = document.getElementById('poi-row'); this._locationBarDivider = document.getElementById('location-bar-divider'); @@ -9342,6 +9354,207 @@ export class StyleManager { } } }); + + this._initMyLocationControl(); + } + + /** + * Wire the My Location control: click = one-shot fly-to GPS, hold = toggle follow. + * @returns {void} + */ + _initMyLocationControl() { + if (!this._myLocationBtn) return; + if (!isGeolocationAvailable()) { + this._myLocationBtn.disabled = true; + this._myLocationBtn.title = 'Location not supported in this browser'; + return; + } + + const gesture = createPressGesture({ + holdMs: 550, + onClick: () => { + void this._goToMyLocation({ follow: false }); + }, + onHold: () => { + if (this._myLocationFollow) this._stopMyLocationFollow({ toast: 'Stopped following location' }); + else void this._goToMyLocation({ follow: true }); + }, + }); + this._myLocationDetachGesture = gesture.attach(this._myLocationBtn); + } + + /** + * Request browser GPS and fly (and optionally follow) the camera there. + * @param {{follow?: boolean}} [options] + * @returns {Promise} + */ + async _goToMyLocation({ follow = false } = {}) { + if (this._disposed) return; + if (!isGeolocationAvailable()) { + this._showToast('Location not supported in this browser'); + return; + } + + const generation = this._beginDeferredNavigation('location'); + if (generation === false) return; + + this._myLocationBtn?.classList.add('searching'); + try { + const fix = await getCurrentBrowserPosition(); + if (this._disposed || generation !== this._navigationGeneration) return; + if (!this._reassertNavigationHandoff(generation)) return; + + this._applyMyLocationFix(fix, { follow, generation }); + } catch (err) { + if (this._disposed || generation !== this._navigationGeneration) return; + this._showToast(err?.message || 'Could not get your location'); + if (follow) this._stopMyLocationFollow(); + } finally { + this._myLocationBtn?.classList.remove('searching'); + } + } + + /** + * Apply a GPS fix: marker, mini-status, fly-to, and optional live follow. + * @param {{lat:number,lon:number,accuracy?:number|null}} fix + * @param {{follow?:boolean, generation?:number|null, animate?:boolean}} [options] + * @returns {void} + */ + _applyMyLocationFix(fix, { follow = false, generation = null, animate = true } = {}) { + if (this._disposed || !fix) return; + if (generation != null && generation !== this._navigationGeneration) return; + + const range = Math.min( + Math.max(Number(fix.accuracy) > 0 ? Number(fix.accuracy) * 4 : 900, 400), + 2500, + ); + + this._ensureMyLocationEntity(fix); + this._searchedLocationLabel = 'My location'; + this._setActiveLocation(null); + this._currentPoi = null; + this._collapsePOIRow(); + this._updateLocationMiniStatus(); + + if (animate) { + const flight = flyToLandmark(this.viewer, fix.lat, fix.lon, { + range, + pitch: -45, + buildingHeight: 0, + duration: 2.2, + onStart: () => this._beginWorldJumpTransition(), + onComplete: () => this._endWorldJumpTransition(), + onCancel: () => this._endWorldJumpTransition(), + }); + this._currentTarget = flight?.targetPosition || null; + } else if (this._myLocationFollow && this.viewer?.camera) { + const target = Cesium.Cartesian3.fromDegrees(fix.lon, fix.lat, 0); + this._currentTarget = target; + this.viewer.camera.lookAt( + target, + new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-45), range), + ); + this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + } + + if (follow) this._startMyLocationFollow(fix); + } + + /** + * Create or update the temporary "you are here" entity. + * @param {{lat:number,lon:number}} fix + * @returns {void} + */ + _ensureMyLocationEntity(fix) { + if (!this.viewer?.entities) return; + const position = Cesium.Cartesian3.fromDegrees(fix.lon, fix.lat, 0); + if (!this._myLocationEntity) { + this._myLocationEntity = this.viewer.entities.add({ + id: 'gev-my-location', + position, + point: { + pixelSize: 12, + color: Cesium.Color.fromCssColorString('#40b4ff'), + outlineColor: Cesium.Color.WHITE, + outlineWidth: 2, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + label: { + text: 'You', + font: '12px sans-serif', + fillColor: Cesium.Color.WHITE, + outlineColor: Cesium.Color.BLACK, + outlineWidth: 2, + style: Cesium.LabelStyle.FILL_AND_OUTLINE, + pixelOffset: new Cesium.Cartesian2(0, -18), + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + return; + } + this._myLocationEntity.position = position; + } + + /** + * Begin live GPS follow after an initial fix. + * @param {{lat:number,lon:number}} initialFix + * @returns {void} + */ + _startMyLocationFollow(initialFix) { + this._stopMyLocationFollow({ toast: null, keepEntity: true }); + this._myLocationFollow = true; + this._myLocationBtn?.classList.add('following'); + this._myLocationBtn?.setAttribute('aria-pressed', 'true'); + this._myLocationBtn && (this._myLocationBtn.title = 'Following you — hold to stop'); + this._showToast('Following your location'); + this._ensureMyLocationEntity(initialFix); + + this._myLocationUserInterruptHandler = () => { + if (!this._myLocationFollow) return; + this._stopMyLocationFollow({ toast: 'Stopped following location' }); + }; + this.viewer?.canvas?.addEventListener('pointerdown', this._myLocationUserInterruptHandler); + this.viewer?.canvas?.addEventListener('wheel', this._myLocationUserInterruptHandler, { passive: true }); + + this._myLocationWatch = watchBrowserPosition({ + onUpdate: (fix) => { + if (this._disposed || !this._myLocationFollow) return; + this._applyMyLocationFix(fix, { follow: false, animate: false }); + }, + onError: (err) => { + if (this._disposed) return; + this._showToast(err?.message || 'Could not get your location'); + this._stopMyLocationFollow(); + }, + }); + } + + /** + * Stop live GPS follow and optionally remove the marker. + * @param {{toast?:string|null, keepEntity?:boolean}} [options] + * @returns {void} + */ + _stopMyLocationFollow({ toast = null, keepEntity = false } = {}) { + this._myLocationWatch?.stop?.(); + this._myLocationWatch = null; + this._myLocationFollow = false; + this._myLocationBtn?.classList.remove('following'); + this._myLocationBtn?.setAttribute('aria-pressed', 'false'); + if (this._myLocationBtn) { + this._myLocationBtn.title = 'My location (click: go there · hold: follow)'; + } + if (this._myLocationUserInterruptHandler) { + this.viewer?.canvas?.removeEventListener('pointerdown', this._myLocationUserInterruptHandler); + this.viewer?.canvas?.removeEventListener('wheel', this._myLocationUserInterruptHandler); + this._myLocationUserInterruptHandler = null; + } + if (!keepEntity && this._myLocationEntity && this.viewer?.entities) { + this.viewer.entities.remove(this._myLocationEntity); + this._myLocationEntity = null; + } + if (toast) this._showToast(toast); } /** @@ -10234,6 +10447,9 @@ export class StyleManager { document.removeEventListener('keydown', this._poiKeydownHandler); this._poiKeydownHandler = null; } + this._myLocationDetachGesture?.(); + this._myLocationDetachGesture = null; + this._stopMyLocationFollow({ toast: null, keepEntity: false }); // Cancel the rAF animation loop and release its governor hold; also stop // the traffic-chip ticker the loop no longer carries. (perf wave 2 fix) if (this._animFrameId) { diff --git a/style.css b/style.css index 3941027..4e6faa7 100644 --- a/style.css +++ b/style.css @@ -1842,6 +1842,17 @@ body.ui-clean-view #scene-runtime.active { border-color: var(--glass-border-hover); } +.my-location-btn.following, +.my-location-btn[aria-pressed="true"] { + background: rgba(64, 180, 255, 0.22); + border-color: rgba(64, 180, 255, 0.55); + box-shadow: 0 0 0 1px rgba(64, 180, 255, 0.25); +} + +.my-location-btn.searching { + opacity: 0.65; +} + #location-search { width: 0; padding: 0;