mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
1018 lines
35 KiB
JavaScript
1018 lines
35 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 { greatCircleKm } from './geoDistance.js';
|
|
import {
|
|
viewportBias,
|
|
placesNearViewRecovery,
|
|
} from './annotations/annotationResolver.js';
|
|
import { unavailablePlaceSearch } from './search/placeSearch.js';
|
|
import { cancelCameraArrival } from './data/cameraArrival.js';
|
|
import { guardCameraAboveGround } from './cameraGroundGuard.js';
|
|
import { CITY_POIS } from './cityPois.js';
|
|
|
|
/** Newest landmark arrival; an older post-arrival ground guard yields to it. */
|
|
let arrivalGeneration = 0;
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* 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(
|
|
{ lat: anchorLat, lon: anchorLng },
|
|
{ lat: metrics.centerLat, lon: 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;
|
|
}
|