Merge 29daaccb14 into 759652207f
This commit is contained in:
commit
81786cbd5e
|
|
@ -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">📍</span>
|
||||
</button>
|
||||
<button id="search-toggle" class="search-toggle-btn" title="Search any location">
|
||||
<span>🔎</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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
218
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<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) {
|
||||
|
|
|
|||
11
style.css
11
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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue