This commit is contained in:
Walter A. Boring IV 2026-09-09 14:07:48 -04:00 committed by GitHub
commit 81786cbd5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 627 additions and 1 deletions

View File

@ -562,6 +562,9 @@
<div class="location-city-row">
<div id="location-pills" class="location-pills"></div>
<div class="location-search-wrap">
<button id="my-location-btn" class="search-toggle-btn my-location-btn" type="button" title="My location (click: go there · hold: follow)" aria-label="My location" aria-pressed="false">
<span aria-hidden="true">&#x1F4CD;</span>
</button>
<button id="search-toggle" class="search-toggle-btn" title="Search any location">
<span>&#x1F50E;</span>
</button>

251
src/browserGeolocation.js Normal file
View File

@ -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);
};
},
};
}

View File

@ -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();
});

218
src/ui.js
View File

@ -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<void>}
*/
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) {

View File

@ -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;