mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Normalize GeoJSON ground footprints, preserve explicit KML track heights, and tessellate ground paths before native loading. Validate coordinates, geometry types and decoded image budgets before allocating native resources; strip loader-sensitive metadata and settle deferred failures and cancellation. Render static pins in owned billboard collections instead of walking tens of thousands of point entities each frame. Refine nearby ground heights in batches, isolate imported pins from height picks, and release owned resources and point selections on removal. Use safe ray origins above a hidden coarse globe and retain valid mesh heights outside the current footprint. Frame complete geometry with viewport-aware nadir arrivals, including walls and rotated overlays; respect camera gestures and accept document file drops. Strengthen real-loader, native 3D Tiles, paint, clock and lifecycle coverage. Addresses both P1 and all 25 P2 findings in the follow-up review of #1122.
1539 lines
46 KiB
JavaScript
1539 lines
46 KiB
JavaScript
import { rangeForBoundingSphere } from './data/cameraFraming.js';
|
|
import { isUnavailableCapability } from './sources/capability.js';
|
|
import { createOverpassFeatureSource } from './sources/overpassFeatures.js';
|
|
import { applicationServices } from './services/application.js';
|
|
import * as Cesium from 'cesium';
|
|
import {
|
|
viewportBias,
|
|
placesNearViewRecovery,
|
|
} from './annotations/annotationResolver.js';
|
|
import { unavailablePlaceSearch } from './search/placeSearch.js';
|
|
import { cancelCameraArrival } from './data/cameraArrival.js';
|
|
import { guardCameraAboveGround } from './cameraGroundGuard.js';
|
|
|
|
/** Newest landmark arrival; an older post-arrival ground guard yields to it. */
|
|
let arrivalGeneration = 0;
|
|
|
|
/**
|
|
* Points of Interest per city.
|
|
* Each city has 5 POIs; the first is the default fly-to landmark.
|
|
*
|
|
* Field reference:
|
|
* alt — RANGE (distance from target in meters), NOT absolute altitude
|
|
* heading — optimal camera heading in degrees (0=N, 90=E, 180=S, 270=W)
|
|
* pitch — camera tilt in degrees (negative = looking down)
|
|
* buildingHeight — estimated height of landmark center above ground (meters)
|
|
*/
|
|
export const CITY_POIS = {
|
|
austin: {
|
|
name: 'Austin',
|
|
groundElevation: 150, // meters above WGS84 ellipsoid
|
|
viewBounds: {
|
|
southwest: { lat: 30.1, lng: -97.95 },
|
|
northeast: { lat: 30.52, lng: -97.55 },
|
|
},
|
|
pois: [
|
|
{
|
|
name: 'Texas State Capitol',
|
|
lat: 30.2747,
|
|
lon: -97.7403,
|
|
alt: 550,
|
|
pitch: -28,
|
|
heading: 180,
|
|
buildingHeight: 35,
|
|
},
|
|
{
|
|
name: 'Frost Bank Tower',
|
|
lat: 30.2674,
|
|
lon: -97.7434,
|
|
alt: 550,
|
|
pitch: -22,
|
|
heading: 30,
|
|
buildingHeight: 80,
|
|
},
|
|
{
|
|
name: 'Pennybacker Bridge',
|
|
lat: 30.3451,
|
|
lon: -97.7951,
|
|
alt: 500,
|
|
pitch: -25,
|
|
heading: 90,
|
|
buildingHeight: 40,
|
|
},
|
|
{
|
|
name: 'The Jenga Tower',
|
|
lat: 30.2642,
|
|
lon: -97.75,
|
|
alt: 500,
|
|
pitch: -18,
|
|
heading: 45,
|
|
buildingHeight: 60,
|
|
},
|
|
{
|
|
name: 'UT Tower',
|
|
lat: 30.2862,
|
|
lon: -97.7394,
|
|
alt: 500,
|
|
pitch: -22,
|
|
heading: 180,
|
|
buildingHeight: 50,
|
|
},
|
|
],
|
|
},
|
|
sf: {
|
|
name: 'San Francisco',
|
|
groundElevation: 15,
|
|
viewBounds: {
|
|
southwest: { lat: 37.7, lng: -122.53 },
|
|
northeast: { lat: 37.84, lng: -122.35 },
|
|
},
|
|
pois: [
|
|
{
|
|
name: 'Golden Gate Bridge',
|
|
lat: 37.8199,
|
|
lon: -122.4783,
|
|
alt: 1400,
|
|
pitch: -20,
|
|
heading: 45,
|
|
buildingHeight: 100,
|
|
},
|
|
{
|
|
name: 'Transamerica Pyramid',
|
|
lat: 37.7952,
|
|
lon: -122.4028,
|
|
alt: 500,
|
|
pitch: -25,
|
|
heading: 30,
|
|
buildingHeight: 85,
|
|
},
|
|
{
|
|
name: 'Salesforce Tower',
|
|
lat: 37.7897,
|
|
lon: -122.3972,
|
|
alt: 680,
|
|
pitch: -25,
|
|
heading: 330,
|
|
buildingHeight: 100,
|
|
},
|
|
{
|
|
name: 'Alcatraz Island',
|
|
lat: 37.8267,
|
|
lon: -122.423,
|
|
alt: 800,
|
|
pitch: -30,
|
|
heading: 0,
|
|
buildingHeight: 20,
|
|
},
|
|
{
|
|
name: 'Coit Tower',
|
|
lat: 37.8024,
|
|
lon: -122.4058,
|
|
alt: 420,
|
|
pitch: -30,
|
|
heading: 45,
|
|
buildingHeight: 30,
|
|
},
|
|
],
|
|
},
|
|
nyc: {
|
|
name: 'New York',
|
|
groundElevation: 10,
|
|
viewBounds: {
|
|
southwest: { lat: 40.477, lng: -74.259 },
|
|
northeast: { lat: 40.918, lng: -73.7 },
|
|
},
|
|
pois: [
|
|
{
|
|
name: 'Statue of Liberty',
|
|
lat: 40.6892,
|
|
lon: -74.0445,
|
|
alt: 450,
|
|
pitch: -25,
|
|
heading: 315,
|
|
buildingHeight: 45,
|
|
},
|
|
{
|
|
name: 'Empire State Building',
|
|
lat: 40.7484,
|
|
lon: -73.9857,
|
|
alt: 850,
|
|
pitch: -12,
|
|
heading: 30,
|
|
buildingHeight: 130,
|
|
buildingBounds: { height: 443, width: 130, depth: 75 },
|
|
},
|
|
{
|
|
name: 'One World Trade Center',
|
|
lat: 40.7127,
|
|
lon: -74.0134,
|
|
alt: 850,
|
|
pitch: -25,
|
|
heading: 0,
|
|
buildingHeight: 170,
|
|
},
|
|
{
|
|
name: 'Brooklyn Bridge',
|
|
lat: 40.7061,
|
|
lon: -73.9969,
|
|
alt: 850,
|
|
pitch: -25,
|
|
heading: 45,
|
|
buildingHeight: 40,
|
|
},
|
|
{
|
|
name: 'Chrysler Building',
|
|
lat: 40.7516,
|
|
lon: -73.9755,
|
|
alt: 700,
|
|
pitch: -20,
|
|
heading: 225,
|
|
buildingHeight: 100,
|
|
},
|
|
],
|
|
},
|
|
tokyo: {
|
|
name: 'Tokyo',
|
|
groundElevation: 40,
|
|
viewBounds: {
|
|
southwest: { lat: 35.52, lng: 139.55 },
|
|
northeast: { lat: 35.9, lng: 139.92 },
|
|
},
|
|
pois: [
|
|
{
|
|
name: 'Tokyo Tower',
|
|
lat: 35.6586,
|
|
lon: 139.7454,
|
|
alt: 850,
|
|
pitch: -25,
|
|
heading: 0,
|
|
buildingHeight: 110,
|
|
},
|
|
{
|
|
name: 'Tokyo Skytree',
|
|
lat: 35.7101,
|
|
lon: 139.8107,
|
|
alt: 900,
|
|
pitch: -25,
|
|
heading: 30,
|
|
buildingHeight: 200,
|
|
},
|
|
{
|
|
name: 'Imperial Palace',
|
|
lat: 35.6852,
|
|
lon: 139.7528,
|
|
alt: 900,
|
|
pitch: -35,
|
|
heading: 0,
|
|
buildingHeight: 20,
|
|
},
|
|
{
|
|
name: 'Senso-ji Temple',
|
|
lat: 35.7148,
|
|
lon: 139.7967,
|
|
alt: 400,
|
|
pitch: -30,
|
|
heading: 180,
|
|
buildingHeight: 25,
|
|
},
|
|
{
|
|
name: 'Mode Gakuen Cocoon Tower',
|
|
lat: 35.6929,
|
|
lon: 139.6925,
|
|
alt: 350,
|
|
pitch: -20,
|
|
heading: 30,
|
|
buildingHeight: 70,
|
|
},
|
|
],
|
|
},
|
|
london: {
|
|
name: 'London',
|
|
groundElevation: 15,
|
|
viewBounds: {
|
|
southwest: { lat: 51.28, lng: -0.51 },
|
|
northeast: { lat: 51.7, lng: 0.33 },
|
|
},
|
|
pois: [
|
|
{
|
|
name: 'Tower Bridge',
|
|
lat: 51.5055,
|
|
lon: -0.0754,
|
|
alt: 400,
|
|
pitch: -25,
|
|
heading: 270,
|
|
buildingHeight: 65,
|
|
},
|
|
{
|
|
name: 'The Shard',
|
|
lat: 51.5045,
|
|
lon: -0.0865,
|
|
alt: 850,
|
|
pitch: -20,
|
|
heading: 0,
|
|
buildingHeight: 100,
|
|
},
|
|
{
|
|
name: 'Big Ben / Parliament',
|
|
lat: 51.5007,
|
|
lon: -0.1246,
|
|
alt: 600,
|
|
pitch: -25,
|
|
heading: 180,
|
|
buildingHeight: 50,
|
|
},
|
|
{
|
|
name: "St. Paul's Cathedral",
|
|
lat: 51.5138,
|
|
lon: -0.0984,
|
|
alt: 400,
|
|
pitch: -30,
|
|
heading: 270,
|
|
buildingHeight: 55,
|
|
},
|
|
{
|
|
name: 'The Gherkin',
|
|
lat: 51.5145,
|
|
lon: -0.0803,
|
|
alt: 350,
|
|
pitch: -20,
|
|
heading: 30,
|
|
buildingHeight: 60,
|
|
},
|
|
],
|
|
},
|
|
paris: {
|
|
name: 'Paris',
|
|
groundElevation: 35,
|
|
viewBounds: {
|
|
southwest: { lat: 48.815, lng: 2.224 },
|
|
northeast: { lat: 48.902, lng: 2.47 },
|
|
},
|
|
pois: [
|
|
{
|
|
name: 'Eiffel Tower',
|
|
lat: 48.8584,
|
|
lon: 2.2945,
|
|
alt: 750,
|
|
pitch: -25,
|
|
heading: 315,
|
|
buildingHeight: 150,
|
|
},
|
|
{
|
|
name: 'Arc de Triomphe',
|
|
lat: 48.8738,
|
|
lon: 2.295,
|
|
alt: 400,
|
|
pitch: -28,
|
|
heading: 45,
|
|
buildingHeight: 25,
|
|
},
|
|
{
|
|
name: 'Notre-Dame',
|
|
lat: 48.853,
|
|
lon: 2.3499,
|
|
alt: 400,
|
|
pitch: -25,
|
|
heading: 225,
|
|
buildingHeight: 35,
|
|
},
|
|
{
|
|
name: 'Sacré-Cœur',
|
|
lat: 48.8867,
|
|
lon: 2.3431,
|
|
alt: 400,
|
|
pitch: -30,
|
|
heading: 180,
|
|
buildingHeight: 40,
|
|
},
|
|
{
|
|
name: 'Louvre Pyramid',
|
|
lat: 48.8606,
|
|
lon: 2.3376,
|
|
alt: 500,
|
|
pitch: -35,
|
|
heading: 0,
|
|
buildingHeight: 10,
|
|
},
|
|
],
|
|
},
|
|
dubai: {
|
|
name: 'Dubai',
|
|
groundElevation: 5,
|
|
viewBounds: {
|
|
southwest: { lat: 24.95, lng: 54.9 },
|
|
northeast: { lat: 25.35, lng: 55.55 },
|
|
},
|
|
pois: [
|
|
{
|
|
name: 'Burj Khalifa',
|
|
lat: 25.1972,
|
|
lon: 55.2744,
|
|
alt: 600,
|
|
pitch: -20,
|
|
heading: 200,
|
|
buildingHeight: 270,
|
|
},
|
|
{
|
|
name: 'Burj Al Arab',
|
|
lat: 25.1412,
|
|
lon: 55.1853,
|
|
alt: 500,
|
|
pitch: -25,
|
|
heading: 90,
|
|
buildingHeight: 100,
|
|
},
|
|
{
|
|
name: 'Palm Jumeirah',
|
|
lat: 25.1124,
|
|
lon: 55.139,
|
|
alt: 1200,
|
|
pitch: -40,
|
|
heading: 0,
|
|
buildingHeight: 20,
|
|
},
|
|
{
|
|
name: 'Dubai Frame',
|
|
lat: 25.235,
|
|
lon: 55.3003,
|
|
alt: 400,
|
|
pitch: -25,
|
|
heading: 270,
|
|
buildingHeight: 75,
|
|
},
|
|
{
|
|
name: 'Museum of the Future',
|
|
lat: 25.2197,
|
|
lon: 55.2806,
|
|
alt: 350,
|
|
pitch: -20,
|
|
heading: 30,
|
|
buildingHeight: 35,
|
|
},
|
|
],
|
|
},
|
|
dc: {
|
|
name: 'Washington DC',
|
|
groundElevation: 10,
|
|
viewBounds: {
|
|
southwest: { lat: 38.79, lng: -77.12 },
|
|
northeast: { lat: 38.995, lng: -76.91 },
|
|
},
|
|
pois: [
|
|
{
|
|
name: 'US Capitol',
|
|
lat: 38.8897,
|
|
lon: -77.0091,
|
|
alt: 550,
|
|
pitch: -25,
|
|
heading: 270,
|
|
buildingHeight: 45,
|
|
},
|
|
{
|
|
name: 'Washington Monument',
|
|
lat: 38.8895,
|
|
lon: -77.0353,
|
|
alt: 500,
|
|
pitch: -30,
|
|
heading: 0,
|
|
buildingHeight: 85,
|
|
},
|
|
{
|
|
name: 'Lincoln Memorial',
|
|
lat: 38.8893,
|
|
lon: -77.0502,
|
|
alt: 400,
|
|
pitch: -25,
|
|
heading: 90,
|
|
buildingHeight: 20,
|
|
},
|
|
{
|
|
name: 'Pentagon',
|
|
lat: 38.8711,
|
|
lon: -77.0559,
|
|
alt: 800,
|
|
pitch: -40,
|
|
heading: 0,
|
|
buildingHeight: 20,
|
|
},
|
|
{
|
|
name: 'Jefferson Memorial',
|
|
lat: 38.8814,
|
|
lon: -77.0365,
|
|
alt: 400,
|
|
pitch: -30,
|
|
heading: 0,
|
|
buildingHeight: 25,
|
|
},
|
|
],
|
|
},
|
|
tallinn: {
|
|
name: 'Tallinn',
|
|
groundElevation: 15,
|
|
viewBounds: {
|
|
southwest: { lat: 59.36, lng: 24.6 },
|
|
northeast: { lat: 59.52, lng: 24.92 },
|
|
},
|
|
pois: [
|
|
{
|
|
name: 'Viru Square',
|
|
lat: 59.4366,
|
|
lon: 24.7527,
|
|
alt: 450,
|
|
pitch: -28,
|
|
heading: 60,
|
|
buildingHeight: 25,
|
|
},
|
|
{
|
|
name: 'Old Town / Raekoja plats',
|
|
lat: 59.4372,
|
|
lon: 24.7452,
|
|
alt: 400,
|
|
pitch: -30,
|
|
heading: 180,
|
|
buildingHeight: 20,
|
|
},
|
|
{
|
|
name: 'Teatri väljak',
|
|
lat: 59.4344,
|
|
lon: 24.7514,
|
|
alt: 400,
|
|
pitch: -25,
|
|
heading: 220,
|
|
buildingHeight: 25,
|
|
},
|
|
{
|
|
name: 'Port of Tallinn',
|
|
lat: 59.4445,
|
|
lon: 24.7675,
|
|
alt: 700,
|
|
pitch: -30,
|
|
heading: 90,
|
|
buildingHeight: 20,
|
|
},
|
|
{
|
|
name: 'Ülemiste',
|
|
lat: 59.421,
|
|
lon: 24.792,
|
|
alt: 600,
|
|
pitch: -28,
|
|
heading: 45,
|
|
buildingHeight: 30,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Absolute full-earth camera preset for the zoom_to_globe voice tool. The height
|
|
* must stay inside the app's 'global' view-scale band (>12,000 km — classifyViewScale
|
|
* in gevActions.js) so downstream context/screenshot policy treats it as a globe view,
|
|
* and under the fly_to_location rangeM ceiling (20,000 km).
|
|
*/
|
|
export const GLOBE_VIEW = Object.freeze({
|
|
heightM: 18000000,
|
|
pitchDeg: -90,
|
|
durationS: 2.8,
|
|
});
|
|
|
|
/**
|
|
* Fly straight out to the full-earth globe view, keeping the current sub-camera
|
|
* point centered so the user's continent stays in front of them.
|
|
* @param {Cesium.Viewer} viewer
|
|
* @param {{duration?: number, onComplete?: Function, onCancel?: Function}} options
|
|
* @returns {{latitude: number, longitude: number, heightM: number}}
|
|
*/
|
|
export function flyToGlobeView(viewer, options = {}) {
|
|
const carto = viewer.camera.positionCartographic;
|
|
const longitude = Cesium.Math.toDegrees(carto.longitude);
|
|
const latitude = Cesium.Math.toDegrees(carto.latitude);
|
|
viewer.camera.cancelFlight();
|
|
viewer.camera.flyTo({
|
|
destination: Cesium.Cartesian3.fromDegrees(
|
|
longitude,
|
|
latitude,
|
|
GLOBE_VIEW.heightM,
|
|
),
|
|
orientation: {
|
|
heading: 0,
|
|
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
|
|
roll: 0,
|
|
},
|
|
duration: finitePositive(options.duration) || GLOBE_VIEW.durationS,
|
|
endTransform: Cesium.Matrix4.IDENTITY,
|
|
// Cesium's Camera.flyTo reads `complete`/`cancel`. `onComplete`/`onCancel`
|
|
// are this module's OWN option names and are silently ignored by Cesium —
|
|
// spelling them through to flyTo meant the reset never resolved on the
|
|
// flight's own events and every caller fell back to its watchdog timeout.
|
|
complete: options.onComplete,
|
|
cancel: options.onCancel,
|
|
});
|
|
return { latitude, longitude, heightM: GLOBE_VIEW.heightM };
|
|
}
|
|
|
|
/**
|
|
* Flat list of locations for backward compatibility.
|
|
*/
|
|
export const LOCATIONS = Object.entries(CITY_POIS).map(([id, city]) => ({
|
|
id,
|
|
name: city.name,
|
|
lat: city.pois[0].lat,
|
|
lon: city.pois[0].lon,
|
|
}));
|
|
|
|
/**
|
|
* Fly the camera to a landmark using lookAt-based targeting.
|
|
* Guarantees the target is centered in viewport via flyToBoundingSphere + lookAt.
|
|
*
|
|
* @param {Cesium.Viewer} viewer
|
|
* @param {number} lat - Latitude in degrees
|
|
* @param {number} lon - Longitude in degrees
|
|
* @param {object} options
|
|
* @param {number} options.range - Distance from target in meters (default 500)
|
|
* @param {number} options.pitch - Camera tilt in degrees, negative = down (default -30)
|
|
* @param {number} options.heading - Camera heading in degrees (default 0)
|
|
* @param {number} options.buildingHeight - Estimated landmark center height above ground (default 30)
|
|
* @param {number} options.groundElevation - Fallback ground elevation when terrain isn't loaded (default 0)
|
|
* @param {number} options.duration - Flight duration in seconds (default 3.0)
|
|
* @param {{cachedGroundFloor?: Function, warmGroundFloor?: Function}} [options.ground] -
|
|
* Optional ground-floor service; its cached elevation outranks `groundElevation`
|
|
* @returns {{ targetPosition: Cesium.Cartesian3 }} The computed target for orbit use
|
|
*/
|
|
export function flyToLandmark(viewer, lat, lon, options = {}) {
|
|
cancelCameraArrival(viewer);
|
|
const {
|
|
range = 500,
|
|
pitch = -30,
|
|
heading = 0,
|
|
buildingHeight = 30,
|
|
groundElevation = 0,
|
|
duration = 3.0,
|
|
onStart = null,
|
|
onComplete = null,
|
|
onCancel = null,
|
|
buildingBounds = null,
|
|
ground = null,
|
|
} = options;
|
|
|
|
// Sample terrain height (sync — uses loaded tiles; 0 if globe/terrain not ready)
|
|
const targetCartographic = Cesium.Cartographic.fromDegrees(lon, lat);
|
|
const sampledHeight = viewer.scene.globe?.getHeight(targetCartographic);
|
|
|
|
// Use sampled height if available, otherwise the cached ground-floor
|
|
// elevation (same ellipsoidal datum), then the pre-baked city elevation.
|
|
// Google 3D Tiles don't populate globe terrain, and the preset defaults to sea
|
|
// level, so without the ground-floor read an uncurated destination was framed
|
|
// as if it stood on the ocean and the eye landed underground.
|
|
const realGround = ground?.cachedGroundFloor?.(lat, lon);
|
|
const terrainHeight =
|
|
sampledHeight != null && sampledHeight > 0
|
|
? sampledHeight
|
|
: Number.isFinite(realGround)
|
|
? realGround
|
|
: groundElevation;
|
|
// Warm the cell so the next nearby arrival is already correct.
|
|
try {
|
|
ground?.warmGroundFloor?.([{ lat, lon }]);
|
|
} catch {
|
|
/* best-effort prefetch */
|
|
}
|
|
const generation = ++arrivalGeneration;
|
|
|
|
const bounds = normalizeBuildingBounds(buildingBounds);
|
|
const targetHeight = bounds
|
|
? terrainHeight + bounds.height / 2
|
|
: terrainHeight + buildingHeight;
|
|
const targetPosition = Cesium.Cartesian3.fromDegrees(lon, lat, targetHeight);
|
|
const boundingRadius = bounds ? buildingBoundingRadius(bounds) : 0;
|
|
const framingRange = bounds
|
|
? Math.max(
|
|
rangeForBoundingSphere(viewer, boundingRadius),
|
|
boundingRadius * 1.35,
|
|
)
|
|
: range;
|
|
|
|
const hpr = new Cesium.HeadingPitchRange(
|
|
Cesium.Math.toRadians(heading),
|
|
Cesium.Math.toRadians(pitch),
|
|
framingRange,
|
|
);
|
|
|
|
if (typeof onStart === 'function') {
|
|
try {
|
|
onStart();
|
|
} catch {
|
|
/* no-op */
|
|
}
|
|
}
|
|
|
|
// Fly to target, then lock with lookAt for guaranteed centering
|
|
viewer.camera.flyToBoundingSphere(
|
|
new Cesium.BoundingSphere(targetPosition, boundingRadius),
|
|
{
|
|
offset: hpr,
|
|
duration,
|
|
complete: () => {
|
|
viewer.camera.lookAt(targetPosition, hpr);
|
|
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
|
// Framing used a predicted ground height. Once tiles stream in, measure
|
|
// the rendered surface and lift the eye if it is buried or pressed
|
|
// against it; a newer arrival or a manual gesture ends the check.
|
|
try {
|
|
guardCameraAboveGround(
|
|
viewer,
|
|
{ lat, lon },
|
|
{ isStale: () => generation !== arrivalGeneration },
|
|
);
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
if (typeof onComplete === 'function') {
|
|
try {
|
|
onComplete();
|
|
} catch {
|
|
/* no-op */
|
|
}
|
|
}
|
|
},
|
|
cancel: () => {
|
|
if (typeof onCancel === 'function') {
|
|
try {
|
|
onCancel();
|
|
} catch {
|
|
/* no-op */
|
|
}
|
|
}
|
|
},
|
|
},
|
|
);
|
|
|
|
return {
|
|
targetPosition,
|
|
boundingRadius,
|
|
range: framingRange,
|
|
buildingBounds: bounds,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fly to a preset location by ID (uses the first POI as default).
|
|
* Returns target position for orbit controller.
|
|
*/
|
|
export function flyToPresetLocation(viewer, locationId, options = {}) {
|
|
const city = CITY_POIS[locationId];
|
|
if (!city) return null;
|
|
if (
|
|
options.viewMode === 'overview' &&
|
|
!finitePositive(options.range) &&
|
|
city.viewBounds
|
|
) {
|
|
return flyToViewportBounds(viewer, city.viewBounds, {
|
|
duration: options.duration,
|
|
onStart: options.onStart,
|
|
onComplete: options.onComplete,
|
|
onCancel: options.onCancel,
|
|
navigationMode: 'city-overview',
|
|
});
|
|
}
|
|
const poi = city.pois[0];
|
|
return flyToLandmark(viewer, poi.lat, poi.lon, {
|
|
range: poi.alt,
|
|
pitch: poi.pitch,
|
|
heading: poi.heading || 0,
|
|
buildingHeight: poi.buildingHeight || 30,
|
|
buildingBounds: poi.buildingBounds || null,
|
|
groundElevation: city.groundElevation || 0,
|
|
...options,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fly to a specific POI within a city.
|
|
* Returns target position for orbit controller.
|
|
*/
|
|
export function flyToPOI(viewer, cityId, poiIndex, options = {}) {
|
|
const city = CITY_POIS[cityId];
|
|
if (!city || !city.pois[poiIndex]) return null;
|
|
const poi = city.pois[poiIndex];
|
|
return flyToLandmark(viewer, poi.lat, poi.lon, {
|
|
range: poi.alt,
|
|
pitch: poi.pitch,
|
|
heading: poi.heading || 0,
|
|
buildingHeight: poi.buildingHeight || 30,
|
|
buildingBounds: poi.buildingBounds || null,
|
|
groundElevation: city.groundElevation || 0,
|
|
...options,
|
|
});
|
|
}
|
|
|
|
const POI_STOPWORDS = new Set(['the', 'a', 'an', 'at', 'of', 'in', 'on', 'to']);
|
|
/** Significant lowercased word set of a name (punctuation stripped, stopwords dropped). */
|
|
function poiNameTokens(s) {
|
|
return new Set(
|
|
String(s || '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s]/g, ' ')
|
|
.split(/\s+/)
|
|
.filter((w) => w && !POI_STOPWORDS.has(w)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Find a curated preset POI whose name the query fully names, so a voice "fly to the Texas State
|
|
* Capitol" reuses its hand-tuned camera pose (same framing as the LOCATIONS-panel button) instead
|
|
* of generic geocode framing. Match is order-free word-set CONTAINMENT (the POI name's words must
|
|
* all appear in the query — so "Frost Bank Tower" matches "frost tower bank", and extra words like
|
|
* a trailing city are fine), and the POI name must be ≥2 words so a single shared token ("Texas",
|
|
* "Tower") can't grab the wrong landmark. Returns { cityId, index } or null.
|
|
* @param {string} query
|
|
* @returns {{cityId: string, index: number} | null}
|
|
*/
|
|
export function findPoiByName(query) {
|
|
const q = poiNameTokens(query);
|
|
if (q.size === 0) return null;
|
|
let best = null;
|
|
for (const [cityId, city] of Object.entries(CITY_POIS)) {
|
|
city.pois.forEach((poi, index) => {
|
|
const name = poiNameTokens(poi.name);
|
|
if (name.size < 2) return; // single-word POI names are too ambiguous to match loosely
|
|
const fullyNamed = [...name].every((w) => q.has(w));
|
|
if (fullyNamed && (!best || name.size > best.size))
|
|
best = { cityId, index, size: name.size };
|
|
});
|
|
}
|
|
return best ? { cityId: best.cityId, index: best.index } : null;
|
|
}
|
|
|
|
/** Distinguishes an authority veto from a genuine not-found result. */
|
|
export const CANCELLED_SEARCH = Object.freeze({ cancelled: true });
|
|
|
|
/**
|
|
* Geocode a place name through the supplied service, then fly there at a scale
|
|
* appropriate to the request. Countries and cities use their viewport by
|
|
* default; precise landmarks/buildings use close landmark framing.
|
|
*/
|
|
export async function searchAndFlyTo(viewer, query, options = {}) {
|
|
const { placeSearch = unavailablePlaceSearch, signal } = options;
|
|
signal?.throwIfAborted();
|
|
const beforeFly =
|
|
typeof options.beforeFly === 'function' ? options.beforeFly : null;
|
|
const mayFly = () =>
|
|
!signal?.aborted && (beforeFly === null || beforeFly() !== false);
|
|
const outcome = await placeSearch.geocode(query, {
|
|
bias: viewportBias(viewer),
|
|
signal,
|
|
});
|
|
signal?.throwIfAborted();
|
|
const result = outcome.place;
|
|
let lat = result?.lat;
|
|
let lng = result?.lng;
|
|
let label = result?.label || query;
|
|
let types = result?.types || [];
|
|
let viewport = result?.viewport || null;
|
|
|
|
// Nearby landmark recovery retains precedence over a fallback geocoder hit,
|
|
// but never over an exact answer. Recovery exists to rescue a name that a
|
|
// geocoder read too broadly; a typed coordinate or a bundled name has no
|
|
// ambiguity to rescue, and letting a nearby Places hit win would send an
|
|
// operator who typed "43.1731, -79.0384" to whatever is closest instead.
|
|
const recovered = result?.exact
|
|
? null
|
|
: await (options.recoverNearView || placesNearViewRecovery)(
|
|
viewer,
|
|
query,
|
|
result && !outcome.fallbackUsed ? { lat, lon: lng } : null,
|
|
signal,
|
|
placeSearch,
|
|
);
|
|
signal?.throwIfAborted();
|
|
if (recovered) {
|
|
lat = recovered.lat;
|
|
lng = recovered.lon;
|
|
label = recovered.label || label;
|
|
types = recovered.types || [];
|
|
viewport = placesViewportToBounds(recovered.viewport) || viewport;
|
|
} else if (!result) return null;
|
|
|
|
const requestedRange = finitePositive(options.range);
|
|
const duration = finitePositive(options.duration) || 3.0;
|
|
const navigationMode = geocodeNavigationMode(types);
|
|
|
|
const explicitOverview = options.viewMode === 'overview';
|
|
|
|
// Frame the geocode viewport for area-like modes — and for an EXPLICIT overview ask
|
|
// ("give me an overview of X"), which previously fell through to building range.
|
|
if (
|
|
!requestedRange &&
|
|
!options.forceClose &&
|
|
(shouldFrameGeocodeViewport(navigationMode) || explicitOverview)
|
|
) {
|
|
// Natural regions (mountain ranges, deserts, seas) geocode as area-overview with
|
|
// enormous viewports — fitting the whole box flies the camera to space (owner field
|
|
// test 2026-07-23, "Rocky Mountains"). Frame a capped oblique swath over the center
|
|
// instead. Countries/states (region-overview) intentionally keep whole-place framing.
|
|
const swath =
|
|
navigationMode === 'area-overview' ? regionFramingPlan(viewport) : null;
|
|
if (swath?.mode === 'swath') {
|
|
if (!mayFly()) return CANCELLED_SEARCH;
|
|
flyToLandmark(viewer, swath.centerLat, swath.centerLng, {
|
|
range: swath.rangeM,
|
|
pitch: swath.pitchDeg,
|
|
heading: swath.headingDeg,
|
|
buildingHeight: 0,
|
|
ground: options.ground || null,
|
|
duration,
|
|
onStart: options.onStart,
|
|
onComplete: options.onComplete,
|
|
onCancel: options.onCancel,
|
|
});
|
|
return {
|
|
label,
|
|
navigationMode: 'natural-region-swath',
|
|
rangeM: swath.rangeM,
|
|
};
|
|
}
|
|
// An administrative geocode can carry a viewport far larger than the place
|
|
// anyone means — "Tokyo" is the PREFECTURE, which owns islands ~1,000 km out,
|
|
// and framing that whole box landed the camera in the open Pacific at 2,885 km.
|
|
// A box that is both bigger than any city and not centred on its own geocoded
|
|
// location falls back to a metro box on that location. Everything else — every
|
|
// city, every state, every country, parks and streets — frames untouched.
|
|
//
|
|
// EXCEPT when the caller asked for an overview outright ("show me an overview
|
|
// of Hawaii", voice `viewMode: 'overview'` — gevActions.js). That is an explicit
|
|
// request for the whole administrative area, so the sanity gate stands down:
|
|
// it exists to guess what an ambiguous place name meant, and there is nothing
|
|
// left to guess once the user has said.
|
|
const gateFraming =
|
|
!explicitOverview &&
|
|
(navigationMode === 'city-overview' ||
|
|
navigationMode === 'region-overview');
|
|
const framedViewport = gateFraming
|
|
? placeFramingViewport(viewport, lat, lng, types)
|
|
: viewport;
|
|
const flight = flyToViewportBounds(viewer, framedViewport, {
|
|
duration,
|
|
navigationMode,
|
|
beforeFly: mayFly,
|
|
onStart: options.onStart,
|
|
onComplete: options.onComplete,
|
|
onCancel: options.onCancel,
|
|
});
|
|
if (flight === CANCELLED_SEARCH) return CANCELLED_SEARCH;
|
|
if (flight) {
|
|
return {
|
|
label,
|
|
navigationMode,
|
|
rangeM: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
const shouldResolveBuilding = navigationMode === 'precise-place';
|
|
const buildingBounds = shouldResolveBuilding
|
|
? await resolveBuildingBounds(
|
|
lat,
|
|
lng,
|
|
query,
|
|
options.features ??
|
|
(options.boundaries
|
|
? createOverpassFeatureSource({
|
|
boundarySource: options.boundaries,
|
|
})
|
|
: undefined),
|
|
options.signal,
|
|
)
|
|
: null;
|
|
const range = requestedRange || defaultRangeForNavigationMode(navigationMode);
|
|
// Resolve the real ground height before framing, for the same reason as the
|
|
// coordinate branch: with no detailed outline, a searched place on elevated
|
|
// ground would otherwise be framed against sea level. Resolved before the
|
|
// mayFly() gate so a cancellation still wins the race.
|
|
const flyLat = buildingBounds?.lat ?? lat;
|
|
const flyLon = buildingBounds?.lon ?? lng;
|
|
const ground = options.ground || null;
|
|
await ground?.resolveGroundFloorCellsBounded?.([
|
|
{ lat: flyLat, lon: flyLon },
|
|
]);
|
|
const searchGroundElevation = ground?.cachedGroundFloor?.(flyLat, flyLon);
|
|
if (!mayFly()) return CANCELLED_SEARCH;
|
|
const flight = flyToLandmark(viewer, flyLat, flyLon, {
|
|
range,
|
|
pitch: buildingPitch(buildingBounds),
|
|
heading: 30,
|
|
buildingHeight: 30,
|
|
buildingBounds: isUnavailableCapability(buildingBounds)
|
|
? null
|
|
: buildingBounds,
|
|
...(Number.isFinite(searchGroundElevation)
|
|
? { groundElevation: searchGroundElevation }
|
|
: {}),
|
|
ground,
|
|
duration,
|
|
onStart: options.onStart,
|
|
onComplete: options.onComplete,
|
|
onCancel: options.onCancel,
|
|
});
|
|
return {
|
|
label,
|
|
navigationMode: requestedRange
|
|
? 'explicit-range'
|
|
: options.forceClose
|
|
? navigationMode.replace('-overview', '-close')
|
|
: navigationMode,
|
|
rangeM: Math.round(flight.range),
|
|
...(isUnavailableCapability(buildingBounds)
|
|
? { outlineUnavailable: true, message: 'Detailed outline unavailable' }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
/** Places {low,high} viewport → the geocode {southwest,northeast} bounds shape
|
|
* flyToViewportBounds consumes (used when the Places recovery replaces a geocode). */
|
|
function placesViewportToBounds(vp) {
|
|
const low = vp?.low;
|
|
const high = vp?.high;
|
|
if (
|
|
![low?.latitude, low?.longitude, high?.latitude, high?.longitude].every(
|
|
Number.isFinite,
|
|
)
|
|
)
|
|
return null;
|
|
return {
|
|
southwest: { lat: low.latitude, lng: low.longitude },
|
|
northeast: { lat: high.latitude, lng: high.longitude },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Map a geocode result's `types` to a camera-framing mode. Exported for tests.
|
|
* Parks/campuses/lakes and streets are NOT precise POIs: flying to "Zilker Park" at
|
|
* building range lands on a random rooftop, and a `route` result framed at 250 m looks
|
|
* like the camera picked one arbitrary building on the street (field test 8 / rootcause
|
|
* doc §3) — both frame their geocode viewport instead.
|
|
*/
|
|
export function geocodeNavigationMode(types) {
|
|
const values = new Set(types);
|
|
if (
|
|
values.has('country') ||
|
|
values.has('administrative_area_level_1') ||
|
|
values.has('administrative_area_level_2')
|
|
) {
|
|
return 'region-overview';
|
|
}
|
|
if (values.has('locality') || values.has('postal_town'))
|
|
return 'city-overview';
|
|
if (
|
|
values.has('sublocality') ||
|
|
values.has('sublocality_level_1') ||
|
|
values.has('neighborhood') ||
|
|
values.has('postal_code')
|
|
) {
|
|
return 'neighborhood-close';
|
|
}
|
|
if (values.has('route') || values.has('intersection'))
|
|
return 'street-corridor';
|
|
if (
|
|
values.has('park') ||
|
|
values.has('natural_feature') ||
|
|
values.has('campus') ||
|
|
values.has('university') ||
|
|
values.has('airport') ||
|
|
values.has('stadium') ||
|
|
values.has('amusement_park') ||
|
|
values.has('zoo') ||
|
|
values.has('cemetery') ||
|
|
values.has('shopping_mall')
|
|
) {
|
|
return 'area-overview';
|
|
}
|
|
return 'precise-place';
|
|
}
|
|
|
|
/**
|
|
* Region-scale span (bbox diagonal, km) above which a geocode viewport is a natural
|
|
* REGION (mountain range, desert, sea) rather than a place: framing the whole box
|
|
* would fly the camera to space, so we frame a representative swath instead.
|
|
* Sized so real parks/lakes (Tahoe ~50 km) keep full framing while ranges
|
|
* (Rockies ~2,700 km, Alps ~880 km) go swath.
|
|
*/
|
|
export const REGION_SWATH_SPAN_KM = 400;
|
|
|
|
/** Capped camera range for the representative swath (regional scale, not space). */
|
|
const REGION_SWATH_RANGE_M = 280000;
|
|
|
|
/** Oblique cinematic tilt for the swath — matches the annotation-assist angle. */
|
|
const REGION_SWATH_PITCH_DEG = -35;
|
|
|
|
/** Mean km per degree of latitude (WGS84), the basis for every span estimate here. */
|
|
const KM_PER_DEGREE = 111.32;
|
|
|
|
/**
|
|
* Measure a geocode {southwest,northeast} box. Pure; shared by the region-swath
|
|
* and locality-sanity heuristics so both read the antimeridian the same way.
|
|
* @param {{southwest:{lat:number,lng:number}, northeast:{lat:number,lng:number}}|null} viewport
|
|
* @returns {null | {latSpanDeg:number, lonSpanDeg:number, latSpanKm:number,
|
|
* lonSpanKm:number, spanKm:number, centerLat:number, centerLng:number}}
|
|
*/
|
|
export function viewportMetrics(viewport) {
|
|
const southwest = viewport?.southwest;
|
|
const northeast = viewport?.northeast;
|
|
if (
|
|
!Number.isFinite(southwest?.lat) ||
|
|
!Number.isFinite(southwest?.lng) ||
|
|
!Number.isFinite(northeast?.lat) ||
|
|
!Number.isFinite(northeast?.lng)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const latSpanDeg = northeast.lat - southwest.lat;
|
|
// Longitude span measured the short way round so an antimeridian-crossing box
|
|
// (Pacific features) doesn't read as ~340° wide.
|
|
const lonSpanDeg = (((northeast.lng - southwest.lng) % 360) + 360) % 360;
|
|
const centerLat = (southwest.lat + northeast.lat) / 2;
|
|
let centerLng = southwest.lng + lonSpanDeg / 2;
|
|
if (centerLng > 180) centerLng -= 360;
|
|
|
|
const latSpanKm = Math.abs(latSpanDeg) * KM_PER_DEGREE;
|
|
const lonSpanKm =
|
|
lonSpanDeg * KM_PER_DEGREE * Math.cos(Cesium.Math.toRadians(centerLat));
|
|
return {
|
|
latSpanDeg,
|
|
lonSpanDeg,
|
|
latSpanKm,
|
|
lonSpanKm,
|
|
spanKm: Math.hypot(latSpanKm, lonSpanKm),
|
|
centerLat,
|
|
centerLng,
|
|
};
|
|
}
|
|
|
|
/** Wrap a longitude in degrees into [-180, 180). */
|
|
function wrapLongitude(lng) {
|
|
return ((((lng + 180) % 360) + 360) % 360) - 180;
|
|
}
|
|
|
|
/**
|
|
* Diagonal span (km) above which a geocode viewport is bigger than any city, so
|
|
* the off-centre test below is worth applying. Every locality Google returns is
|
|
* far under this (the widest measured is Anchorage at ~135 km), so a city can
|
|
* never be gated on span alone.
|
|
*/
|
|
export const PLACE_VIEWPORT_MAX_SPAN_KM = 300;
|
|
|
|
/**
|
|
* Fraction of a viewport's diagonal that its own geocoded location may sit away
|
|
* from the box centre before the box is judged NOT to be centred on the place.
|
|
*
|
|
* The 2026-08-20 QA hunt defect: "Tokyo" flew the camera ~977 km from Tokyo, out
|
|
* over the open Pacific at 2,885 km altitude. Tokyo geocodes as
|
|
* `administrative_area_level_1` (the prefecture), and Tokyo Metropolis owns the
|
|
* Izu and Ogasawara chains ~1,000 km out to sea, so its bounding box is mostly
|
|
* ocean and its centroid is nowhere near the city.
|
|
*
|
|
* Neither span nor offset alone separates that from a legitimately huge region;
|
|
* the ratio does. Measured against the live Geocoding API (2026-08-20):
|
|
*
|
|
* gated Tokyo 2,462 km box, anchor 977 km off → ratio 0.397
|
|
* Hawaii 2,642 km box, anchor 1,204 km off → ratio 0.456
|
|
* not Nunavut 4,394 km box, anchor 426 km off → ratio 0.097
|
|
* Alaska 3,820 km box, anchor 337 km off → ratio 0.088
|
|
* Japan 4,047 km box, anchor 357 km off → ratio 0.088
|
|
* Newfoundl. 1,834 km box, anchor 132 km off → ratio 0.072
|
|
* Texas 1,725 km box, anchor 90 km off → ratio 0.052
|
|
* California 1,398 km box, anchor 56 km off → ratio 0.040
|
|
*
|
|
* 0.15 sits in the 2.2x gap between the two groups. States and provinces keep
|
|
* whole-place framing — "California" must still frame California.
|
|
*/
|
|
export const PLACE_ANCHOR_OFFSET_RATIO = 0.15;
|
|
|
|
/**
|
|
* Half-extent of the box synthesized when the gate trips — a 40 x 40 km square,
|
|
* matching the hand-tuned city pills (Tokyo's own pill is 42 x 34 km). Framing a
|
|
* box rather than picking a range keeps the flight on the same tested code path
|
|
* the pills use, so a gated search and its pill land at the same scale.
|
|
*/
|
|
const PLACE_FALLBACK_HALF_SPAN_KM = 20;
|
|
|
|
/** Great-circle distance in km (small enough here that the spherical model is fine). */
|
|
function greatCircleKm(lat1, lng1, lat2, lng2) {
|
|
const dLat = Cesium.Math.toRadians(lat2 - lat1);
|
|
const dLng = Cesium.Math.toRadians(lng2 - lng1);
|
|
const a =
|
|
Math.sin(dLat / 2) ** 2 +
|
|
Math.cos(Cesium.Math.toRadians(lat1)) *
|
|
Math.cos(Cesium.Math.toRadians(lat2)) *
|
|
Math.sin(dLng / 2) ** 2;
|
|
return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(a)));
|
|
}
|
|
|
|
/**
|
|
* Sanity-gate a geocode viewport before framing it. Pure — exported for unit tests.
|
|
*
|
|
* Returns the viewport unchanged unless the box is BOTH bigger than any city AND
|
|
* not actually centred on its own geocoded location; in that case it returns a
|
|
* metro box around `geometry.location` (Tokyo proper) instead of the box centroid
|
|
* (a point in the open Pacific).
|
|
*
|
|
* `country` results are deliberately EXEMPT. Several countries have the identical
|
|
* pathology from overseas territories — France 12,262 km / ratio 0.387, Portugal
|
|
* 0.378, Ecuador 0.303, Chile 0.256, Spain 0.216 — but country framing is shipped,
|
|
* demoed behavior that no one has reviewed a change to, and reframing a country to
|
|
* a 40 km box is a much bigger call than fixing a prefecture. Left as-is on purpose;
|
|
* flagged for the owner rather than changed silently.
|
|
*
|
|
* @param {{southwest:{lat:number,lng:number}, northeast:{lat:number,lng:number}}|null} viewport
|
|
* @param {number} anchorLat Geocode result latitude (`geometry.location`).
|
|
* @param {number} anchorLng Geocode result longitude.
|
|
* @param {string[]} [types] Raw geocode result types, used only for the country exemption.
|
|
* @returns {{southwest:{lat:number,lng:number}, northeast:{lat:number,lng:number}}|null}
|
|
*/
|
|
export function placeFramingViewport(
|
|
viewport,
|
|
anchorLat,
|
|
anchorLng,
|
|
types = [],
|
|
) {
|
|
if (Array.isArray(types) && types.includes('country')) return viewport;
|
|
const metrics = viewportMetrics(viewport);
|
|
if (!metrics || metrics.spanKm <= PLACE_VIEWPORT_MAX_SPAN_KM) return viewport;
|
|
if (!Number.isFinite(anchorLat) || !Number.isFinite(anchorLng))
|
|
return viewport;
|
|
|
|
const offsetKm = greatCircleKm(
|
|
anchorLat,
|
|
anchorLng,
|
|
metrics.centerLat,
|
|
metrics.centerLng,
|
|
);
|
|
if (offsetKm <= metrics.spanKm * PLACE_ANCHOR_OFFSET_RATIO) return viewport;
|
|
|
|
const latHalfDeg = PLACE_FALLBACK_HALF_SPAN_KM / KM_PER_DEGREE;
|
|
// Guard the cosine so a near-polar anchor cannot blow the longitude half-extent up.
|
|
const cosLat = Math.max(0.05, Math.cos(Cesium.Math.toRadians(anchorLat)));
|
|
const lngHalfDeg = PLACE_FALLBACK_HALF_SPAN_KM / (KM_PER_DEGREE * cosLat);
|
|
const wrapLng = (lng) => ((((lng + 180) % 360) + 360) % 360) - 180;
|
|
return {
|
|
southwest: {
|
|
lat: Math.max(-89.9, anchorLat - latHalfDeg),
|
|
lng: wrapLng(anchorLng - lngHalfDeg),
|
|
},
|
|
northeast: {
|
|
lat: Math.min(89.9, anchorLat + latHalfDeg),
|
|
lng: wrapLng(anchorLng + lngHalfDeg),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Natural-region framing heuristic (owner field test 2026-07-23). Pure — exported
|
|
* for unit tests. Given geocode {southwest,northeast} bounds, decide whether to
|
|
* frame the full viewport or a capped oblique swath over the feature's center,
|
|
* looking along the feature's long axis.
|
|
*
|
|
* @param {{southwest:{lat:number,lng:number}, northeast:{lat:number,lng:number}}|null} viewport
|
|
* @returns {null
|
|
* | {mode:'full', spanKm:number}
|
|
* | {mode:'swath', spanKm:number, centerLat:number, centerLng:number,
|
|
* rangeM:number, pitchDeg:number, headingDeg:number}}
|
|
*/
|
|
export function regionFramingPlan(viewport) {
|
|
const metrics = viewportMetrics(viewport);
|
|
if (!metrics) return null;
|
|
const { latSpanKm, lonSpanKm, spanKm, centerLat, centerLng } = metrics;
|
|
|
|
if (spanKm <= REGION_SWATH_SPAN_KM) return { mode: 'full', spanKm };
|
|
|
|
return {
|
|
mode: 'swath',
|
|
spanKm,
|
|
centerLat,
|
|
centerLng,
|
|
rangeM: REGION_SWATH_RANGE_M,
|
|
pitchDeg: REGION_SWATH_PITCH_DEG,
|
|
// Look along the feature's long axis so the swath reads as the range receding
|
|
// toward the horizon (N-S ranges → face north, E-W ranges → face east).
|
|
headingDeg: latSpanKm >= lonSpanKm ? 0 : 90,
|
|
};
|
|
}
|
|
|
|
function defaultRangeForNavigationMode(mode) {
|
|
// Fallback ranges when the geocode has no usable viewport to frame.
|
|
if (mode === 'area-overview') return 1400;
|
|
if (mode === 'street-corridor') return 900;
|
|
return 250;
|
|
}
|
|
|
|
function shouldFrameGeocodeViewport(mode) {
|
|
return (
|
|
mode === 'region-overview' ||
|
|
mode === 'city-overview' ||
|
|
mode === 'area-overview' ||
|
|
mode === 'street-corridor'
|
|
);
|
|
}
|
|
|
|
function flyToViewportBounds(viewer, viewport, options = {}) {
|
|
const {
|
|
duration = 3.0,
|
|
beforeFly = null,
|
|
onStart = null,
|
|
onComplete = null,
|
|
onCancel = null,
|
|
navigationMode = 'overview',
|
|
} = options;
|
|
const southwest = viewport?.southwest;
|
|
const northeast = viewport?.northeast;
|
|
if (
|
|
!Number.isFinite(southwest?.lat) ||
|
|
!Number.isFinite(southwest?.lng) ||
|
|
!Number.isFinite(northeast?.lat) ||
|
|
!Number.isFinite(northeast?.lng)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// Pad from the SHORT-way-round longitude span. Raw subtraction breaks any box
|
|
// that crosses the antimeridian: Alaska (sw 172.3E, ne -130.0) subtracts to
|
|
// -302, and a 0.4-degree metro box straddling the dateline subtracts to -359.6,
|
|
// whose 12% padding alone is 43 degrees — that box framed 86.7 degrees of ocean
|
|
// instead of a city. Cesium's Rectangle does NOT normalize past +/-180, so the
|
|
// padded edges are wrapped here into a proper east<west crossing rectangle.
|
|
const metrics = viewportMetrics(viewport);
|
|
const latitudePadding = Math.max(0.05, Math.abs(metrics.latSpanDeg) * 0.12);
|
|
const longitudePadding = Math.max(0.05, metrics.lonSpanDeg * 0.12);
|
|
const paddedLonSpan = metrics.lonSpanDeg + longitudePadding * 2;
|
|
const south = Math.max(-89.9, southwest.lat - latitudePadding);
|
|
const north = Math.min(89.9, northeast.lat + latitudePadding);
|
|
const rectangle =
|
|
paddedLonSpan >= 360
|
|
? Cesium.Rectangle.fromDegrees(-180, south, 180, north)
|
|
: Cesium.Rectangle.fromDegrees(
|
|
wrapLongitude(southwest.lng - longitudePadding),
|
|
south,
|
|
wrapLongitude(southwest.lng + metrics.lonSpanDeg + longitudePadding),
|
|
north,
|
|
);
|
|
if (typeof beforeFly === 'function' && beforeFly() === false)
|
|
return CANCELLED_SEARCH;
|
|
if (typeof onStart === 'function') {
|
|
try {
|
|
onStart();
|
|
} catch {
|
|
/* no-op */
|
|
}
|
|
}
|
|
viewer.camera.flyTo({
|
|
destination: rectangle,
|
|
duration,
|
|
endTransform: Cesium.Matrix4.IDENTITY,
|
|
complete: () => {
|
|
if (typeof onComplete === 'function') {
|
|
try {
|
|
onComplete();
|
|
} catch {
|
|
/* no-op */
|
|
}
|
|
}
|
|
},
|
|
cancel: () => {
|
|
if (typeof onCancel === 'function') {
|
|
try {
|
|
onCancel();
|
|
} catch {
|
|
/* no-op */
|
|
}
|
|
}
|
|
},
|
|
});
|
|
// Same short-way-round rule for the reported centre: averaging raw longitudes
|
|
// puts a dateline-crossing box's centre on the opposite side of the planet.
|
|
return {
|
|
targetPosition: Cesium.Cartesian3.fromDegrees(
|
|
metrics.centerLng,
|
|
metrics.centerLat,
|
|
0,
|
|
),
|
|
boundingRadius: 0,
|
|
range: null,
|
|
viewBounds: viewport,
|
|
navigationMode,
|
|
};
|
|
}
|
|
|
|
function normalizeBuildingBounds(bounds) {
|
|
if (!bounds) return null;
|
|
const height = finitePositive(bounds.height);
|
|
const width = finitePositive(bounds.width);
|
|
const depth = finitePositive(bounds.depth);
|
|
if (!height || !width || !depth) return null;
|
|
return { ...bounds, height, width, depth };
|
|
}
|
|
|
|
function buildingBoundingRadius(bounds) {
|
|
const halfHeight = bounds.height / 2;
|
|
const halfWidth = bounds.width / 2;
|
|
const halfDepth = bounds.depth / 2;
|
|
return Math.hypot(halfHeight, halfWidth, halfDepth) * 1.18;
|
|
}
|
|
|
|
function buildingPitch(bounds) {
|
|
if (!bounds) return -25;
|
|
const footprint = Math.max(bounds.width, bounds.depth);
|
|
const ratio = bounds.height / Math.max(footprint, 1);
|
|
if (ratio >= 2.5) return -12;
|
|
if (ratio >= 1.2) return -22;
|
|
if (ratio <= 0.35) return -45;
|
|
return -32;
|
|
}
|
|
|
|
async function resolveBuildingBounds(
|
|
lat,
|
|
lon,
|
|
query,
|
|
features = applicationServices.features,
|
|
signal,
|
|
) {
|
|
try {
|
|
const candidates = await features.getFocusFootprints(
|
|
{ lat, lon },
|
|
{ signal },
|
|
);
|
|
if (isUnavailableCapability(candidates)) return candidates;
|
|
return selectBuildingBounds(
|
|
Array.isArray(candidates) ? candidates : [],
|
|
lat,
|
|
lon,
|
|
query,
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function selectBuildingBounds(elements, targetLat, targetLon, query) {
|
|
const queryWords = normalizedWords(query);
|
|
const candidates = [];
|
|
for (const element of elements) {
|
|
const coordinates = element.coordinates;
|
|
if (coordinates.length < 3) continue;
|
|
const bounds = coordinateBounds(coordinates, targetLat);
|
|
if (!bounds || bounds.width < 2 || bounds.depth < 2) continue;
|
|
const names = element.names;
|
|
const center = element.center || averageCoordinate(coordinates);
|
|
const distanceM = approximateDistanceM(
|
|
targetLat,
|
|
targetLon,
|
|
center.lat,
|
|
center.lon,
|
|
);
|
|
const nameWords = normalizedWords(
|
|
[names.primary, names.english, names.official, names.alternate]
|
|
.filter(Boolean)
|
|
.join(' '),
|
|
);
|
|
const nameScore = wordOverlap(queryWords, nameWords);
|
|
const containsTarget = pointInPolygon(targetLon, targetLat, coordinates);
|
|
const height =
|
|
element.heightM ??
|
|
Math.max(12, Math.min(80, Math.max(bounds.width, bounds.depth) * 0.8));
|
|
candidates.push({
|
|
lat: center.lat,
|
|
lon: center.lon,
|
|
height,
|
|
width: bounds.width,
|
|
depth: bounds.depth,
|
|
osmName: names.primary || names.english || null,
|
|
osmType: element.provenance?.type,
|
|
osmId: element.provenance?.id,
|
|
score: nameScore * 1000 + (containsTarget ? 500 : 0) - distanceM,
|
|
});
|
|
}
|
|
if (!candidates.length) return null;
|
|
candidates.sort((a, b) => b.score - a.score);
|
|
const { score, ...best } = candidates[0];
|
|
return best;
|
|
}
|
|
|
|
function coordinateBounds(coordinates, latitude) {
|
|
const latitudes = coordinates.map((point) => point.lat);
|
|
const longitudes = coordinates.map((point) => point.lon);
|
|
const south = Math.min(...latitudes);
|
|
const north = Math.max(...latitudes);
|
|
const west = Math.min(...longitudes);
|
|
const east = Math.max(...longitudes);
|
|
return {
|
|
width: approximateDistanceM(latitude, west, latitude, east),
|
|
depth: approximateDistanceM(south, west, north, west),
|
|
};
|
|
}
|
|
|
|
function averageCoordinate(coordinates) {
|
|
const total = coordinates.reduce(
|
|
(sum, point) => ({
|
|
lat: sum.lat + point.lat,
|
|
lon: sum.lon + point.lon,
|
|
}),
|
|
{ lat: 0, lon: 0 },
|
|
);
|
|
return {
|
|
lat: total.lat / coordinates.length,
|
|
lon: total.lon / coordinates.length,
|
|
};
|
|
}
|
|
|
|
function normalizedWords(value) {
|
|
return new Set(
|
|
String(value || '')
|
|
.toLowerCase()
|
|
.normalize('NFKD')
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter((word) => word.length > 2),
|
|
);
|
|
}
|
|
|
|
function wordOverlap(left, right) {
|
|
let matches = 0;
|
|
for (const word of left) {
|
|
if (right.has(word)) matches++;
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
function pointInPolygon(lon, lat, coordinates) {
|
|
let inside = false;
|
|
for (
|
|
let index = 0, previous = coordinates.length - 1;
|
|
index < coordinates.length;
|
|
previous = index++
|
|
) {
|
|
const a = coordinates[index];
|
|
const b = coordinates[previous];
|
|
const intersects =
|
|
a.lat > lat !== b.lat > lat &&
|
|
lon <
|
|
((b.lon - a.lon) * (lat - a.lat)) / (b.lat - a.lat || Number.EPSILON) +
|
|
a.lon;
|
|
if (intersects) inside = !inside;
|
|
}
|
|
return inside;
|
|
}
|
|
|
|
function approximateDistanceM(latA, lonA, latB, lonB) {
|
|
const latitudeScale = 111320;
|
|
const longitudeScale =
|
|
latitudeScale * Math.cos(Cesium.Math.toRadians((latA + latB) / 2));
|
|
return Math.hypot(
|
|
(latB - latA) * latitudeScale,
|
|
(lonB - lonA) * longitudeScale,
|
|
);
|
|
}
|
|
|
|
function finitePositive(value) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
}
|