mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat(street-level): place coverage on the bare earth on Google 3D
Draped ground polylines and clamped billboards land on the top of the Google Photorealistic 3D mesh, so on a street under trees or an overpass the coverage seemed to float at roof height. At street zoom on Google 3D (camera below 1,400 m, back above 1,800 m) the core switches to a `terrain` surface mode. groundCast.js samples the existing terrain service on the ground-floor grid (0.001 deg), interpolates bilinearly and adds 2 m; Mapillary lines become plain polylines at those heights, and cones and the position marker stand on the street, so the mesh hides what is behind buildings and trees. A tile draws draped first and swaps to its cast lines once the heights arrive, keeping the old lines until the new primitives are ready. Lookups are once per tile and aborted when the tile is dropped; a geoid fallback counts as unknown and leaves lines draped. Other map stacks and higher views are unchanged. Providers get getSurface() and groundCaster in their context and an optional setSurface(mode); getUIState() exposes `surface`. The QA gate checks draped on Esri and terrain on Google 3D. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c7dc3146f2
commit
d3d3898dbb
17 files changed
+832
-49
No files matched your search
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Ground casting for street-level overlays on Google Photorealistic 3D.
|
||||
*
|
||||
* Draped lines and clamped billboards land on the top of the photoreal mesh,
|
||||
* so on a street lined with trees or under an overpass the coverage appears
|
||||
* to float at roof height. Casting places the overlays at the bare-earth
|
||||
* height from the terrain service (DEM) instead, plus a small lift, and lets
|
||||
* the mesh hide what is behind buildings.
|
||||
*
|
||||
* Heights are sampled on a coarse grid and interpolated, so one z14 coverage
|
||||
* tile costs at most a few hundred terrain lookups, and the grid matches the
|
||||
* ground-floor service (3 decimals) so cached cells are shared. Only real DEM
|
||||
* heights count; a geoid fallback (terrain proxy down) is treated as unknown
|
||||
* and the caller keeps its draped placement.
|
||||
*/
|
||||
|
||||
/** Grid spacing in degrees (~111 m north-south): the ground-floor grid. */
|
||||
export const GROUND_CAST_STEP_DEG = 0.001;
|
||||
/** Longest line segment left undivided, in degrees, so lines follow the grid. */
|
||||
export const GROUND_CAST_DENSIFY_DEG = 0.0005;
|
||||
/** Metres above the bare earth, so lines sit on the road instead of in it. */
|
||||
export const GROUND_CAST_LIFT_M = 2;
|
||||
/** Grid corners one prepare call may request; more stays draped. */
|
||||
export const GROUND_CAST_MAX_CORNERS = 1024;
|
||||
/** Cached grid corners before the cache is dropped and refilled. */
|
||||
const GROUND_CAST_CACHE_MAX = 50_000;
|
||||
|
||||
/** Camera height (m above ground) below which Google 3D switches to terrain mode. */
|
||||
export const SURFACE_TERRAIN_ENTER_M = 1400;
|
||||
/** Camera height above which terrain mode switches back to draped. */
|
||||
export const SURFACE_TERRAIN_EXIT_M = 1800;
|
||||
|
||||
/** Surface modes: draped on whatever the globe shows, or cast to bare earth. */
|
||||
export const SURFACE_MODES = Object.freeze(['draped', 'terrain']);
|
||||
|
||||
/**
|
||||
* The next surface mode for the camera. Terrain mode applies only on Google
|
||||
* 3D and only at street zoom; the gap between the enter and exit heights
|
||||
* stops the mode from flapping while the camera hovers at the boundary.
|
||||
* @param {'draped'|'terrain'} current
|
||||
* @param {{photoreal: boolean, heightM: number|null, available: boolean}} view
|
||||
* @returns {'draped'|'terrain'}
|
||||
*/
|
||||
export function nextSurfaceMode(current, { photoreal, heightM, available }) {
|
||||
if (!available || !photoreal || !Number.isFinite(heightM)) return 'draped';
|
||||
if (current === 'terrain')
|
||||
return heightM > SURFACE_TERRAIN_EXIT_M ? 'draped' : 'terrain';
|
||||
return heightM < SURFACE_TERRAIN_ENTER_M ? 'terrain' : 'draped';
|
||||
}
|
||||
|
||||
/**
|
||||
* Split long segments so a line follows the terrain between its vertices.
|
||||
* @param {Array<[number, number]>} coords [lon, lat] pairs
|
||||
* @param {number} [maxStep] longest segment, in degrees
|
||||
* @returns {Array<[number, number]>}
|
||||
*/
|
||||
export function densifyLine(coords, maxStep = GROUND_CAST_DENSIFY_DEG) {
|
||||
if (!Array.isArray(coords) || coords.length < 2) return coords || [];
|
||||
const out = [coords[0]];
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
const [lon0, lat0] = coords[i - 1];
|
||||
const [lon1, lat1] = coords[i];
|
||||
const span = Math.max(Math.abs(lon1 - lon0), Math.abs(lat1 - lat0));
|
||||
const parts = Math.min(64, Math.ceil(span / maxStep - 1e-9));
|
||||
for (let k = 1; k < parts; k++) {
|
||||
const t = k / parts;
|
||||
out.push([lon0 + (lon1 - lon0) * t, lat0 + (lat1 - lat0) * t]);
|
||||
}
|
||||
out.push(coords[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare-earth height cache over the terrain service.
|
||||
* @param {{terrain: {resolveEllipsoidalGround: Function}, step?: number, lift?: number, maxCorners?: number}} options
|
||||
*/
|
||||
export function createGroundCaster({
|
||||
terrain,
|
||||
step = GROUND_CAST_STEP_DEG,
|
||||
lift = GROUND_CAST_LIFT_M,
|
||||
maxCorners = GROUND_CAST_MAX_CORNERS,
|
||||
}) {
|
||||
if (typeof terrain?.resolveEllipsoidalGround !== 'function')
|
||||
throw new TypeError('Ground casting requires a terrain service');
|
||||
/** "i,j" grid corner → ellipsoidal ground height in metres. */
|
||||
const heights = new Map();
|
||||
/** Prepares run one at a time, so neighbouring tiles share their corners. */
|
||||
let queue = Promise.resolve();
|
||||
|
||||
const cornerKey = (i, j) => `${i},${j}`;
|
||||
|
||||
function missingCorners(points) {
|
||||
const missing = new Map();
|
||||
for (const point of points) {
|
||||
const [lon, lat] = point;
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||
const i0 = Math.floor(lon / step);
|
||||
const j0 = Math.floor(lat / step);
|
||||
for (let di = 0; di <= 1; di++)
|
||||
for (let dj = 0; dj <= 1; dj++) {
|
||||
const key = cornerKey(i0 + di, j0 + dj);
|
||||
if (!heights.has(key) && !missing.has(key))
|
||||
missing.set(key, { i: i0 + di, j: j0 + dj });
|
||||
}
|
||||
}
|
||||
return [...missing.entries()];
|
||||
}
|
||||
|
||||
async function resolve(points, signal) {
|
||||
if (signal?.aborted) return false;
|
||||
const missing = missingCorners(points);
|
||||
if (!missing.length) return true;
|
||||
if (missing.length > maxCorners) return false;
|
||||
const results = await terrain.resolveEllipsoidalGround(
|
||||
missing.map(([, { i, j }]) => ({
|
||||
lon: Number((i * step).toFixed(6)),
|
||||
lat: Number((j * step).toFixed(6)),
|
||||
})),
|
||||
);
|
||||
if (heights.size + missing.length > GROUND_CAST_CACHE_MAX) heights.clear();
|
||||
let complete = true;
|
||||
missing.forEach(([key], n) => {
|
||||
const result = results?.[n];
|
||||
if (result?.source === 'reearth' && Number.isFinite(result.ellipsoid))
|
||||
heights.set(key, result.ellipsoid);
|
||||
else complete = false;
|
||||
});
|
||||
return complete && !signal?.aborted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the grid corners around the given points. Resolves true when every
|
||||
* point can now be cast; false when some corners are unknown (terrain proxy
|
||||
* down, too many corners, or aborted). Never rejects.
|
||||
* @param {Array<[number, number]>} points [lon, lat] pairs
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
*/
|
||||
function prepare(points, { signal } = {}) {
|
||||
const run = queue.then(() => resolve(points, signal)).catch(() => false);
|
||||
queue = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
/** `prepare` for lines, including the points densifying adds. */
|
||||
function prepareLines(lines, options) {
|
||||
return prepare(
|
||||
lines.flatMap((coords) => densifyLine(coords)),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** Bare-earth ellipsoidal height at a point, from cached corners only; null when unknown. */
|
||||
function groundAt(lon, lat) {
|
||||
const x = lon / step;
|
||||
const y = lat / step;
|
||||
const i0 = Math.floor(x);
|
||||
const j0 = Math.floor(y);
|
||||
const h00 = heights.get(cornerKey(i0, j0));
|
||||
const h10 = heights.get(cornerKey(i0 + 1, j0));
|
||||
const h01 = heights.get(cornerKey(i0, j0 + 1));
|
||||
const h11 = heights.get(cornerKey(i0 + 1, j0 + 1));
|
||||
if (h00 === undefined || h10 === undefined) return null;
|
||||
if (h01 === undefined || h11 === undefined) return null;
|
||||
const fx = x - i0;
|
||||
const fy = y - j0;
|
||||
const south = h00 + (h10 - h00) * fx;
|
||||
const north = h01 + (h11 - h01) * fx;
|
||||
return south + (north - south) * fy;
|
||||
}
|
||||
|
||||
/** Where an overlay goes: bare earth plus the lift; null when unknown. */
|
||||
function heightAt(lon, lat) {
|
||||
const ground = groundAt(lon, lat);
|
||||
return ground === null ? null : ground + lift;
|
||||
}
|
||||
|
||||
/**
|
||||
* A line as flat [lon, lat, height, ...] degrees and metres, densified;
|
||||
* null when any corner it needs is not cached yet.
|
||||
* @param {Array<[number, number]>} coords
|
||||
*/
|
||||
function castLine(coords) {
|
||||
const flat = [];
|
||||
for (const [lon, lat] of densifyLine(coords)) {
|
||||
const height = heightAt(lon, lat);
|
||||
if (height === null) return null;
|
||||
flat.push(lon, lat, height);
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
return { prepare, prepareLines, groundAt, heightAt, castLine };
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
GROUND_CAST_LIFT_M,
|
||||
SURFACE_TERRAIN_ENTER_M,
|
||||
SURFACE_TERRAIN_EXIT_M,
|
||||
createGroundCaster,
|
||||
densifyLine,
|
||||
nextSurfaceMode,
|
||||
} from './groundCast.js';
|
||||
|
||||
/** A terrain service whose ground rises 1 m per 0.001° east and 2 m per 0.001° north. */
|
||||
function fakeTerrain({ source = 'reearth', fail = false } = {}) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async resolveEllipsoidalGround(coords) {
|
||||
calls.push(coords);
|
||||
if (fail) throw new Error('proxy down');
|
||||
return coords.map(({ lon, lat }) => ({
|
||||
ellipsoid: 100 + (lon - 10) * 1000 + (lat - 50) * 2000,
|
||||
source,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('surface mode: terrain only on Google 3D at street zoom, with hysteresis', () => {
|
||||
const view = { photoreal: true, available: true };
|
||||
assert.equal(nextSurfaceMode('draped', { ...view, heightM: 500 }), 'terrain');
|
||||
assert.equal(
|
||||
nextSurfaceMode('draped', {
|
||||
...view,
|
||||
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
|
||||
}),
|
||||
'draped',
|
||||
);
|
||||
assert.equal(
|
||||
nextSurfaceMode('terrain', {
|
||||
...view,
|
||||
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
|
||||
}),
|
||||
'terrain',
|
||||
);
|
||||
assert.equal(
|
||||
nextSurfaceMode('terrain', {
|
||||
...view,
|
||||
heightM: SURFACE_TERRAIN_EXIT_M + 1,
|
||||
}),
|
||||
'draped',
|
||||
);
|
||||
assert.equal(
|
||||
nextSurfaceMode('terrain', { ...view, photoreal: false, heightM: 200 }),
|
||||
'draped',
|
||||
);
|
||||
assert.equal(
|
||||
nextSurfaceMode('terrain', { ...view, available: false, heightM: 200 }),
|
||||
'draped',
|
||||
);
|
||||
assert.equal(
|
||||
nextSurfaceMode('terrain', { ...view, heightM: null }),
|
||||
'draped',
|
||||
);
|
||||
});
|
||||
|
||||
test('densifyLine splits long segments and keeps the vertices', () => {
|
||||
const line = [
|
||||
[10, 50],
|
||||
[10.002, 50],
|
||||
];
|
||||
const dense = densifyLine(line, 0.0005);
|
||||
assert.equal(dense.length, 5);
|
||||
assert.deepEqual(dense[0], [10, 50]);
|
||||
assert.deepEqual(dense.at(-1), [10.002, 50]);
|
||||
assert.ok(Math.abs(dense[2][0] - 10.001) < 1e-9);
|
||||
const short = [
|
||||
[10, 50],
|
||||
[10.0001, 50],
|
||||
];
|
||||
assert.deepEqual(densifyLine(short, 0.0005), short);
|
||||
assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]);
|
||||
});
|
||||
|
||||
test('the caster interpolates cached grid corners and adds the lift', async () => {
|
||||
const terrain = fakeTerrain();
|
||||
const caster = createGroundCaster({ terrain, step: 0.001 });
|
||||
assert.equal(caster.heightAt(10.0005, 50.0005), null);
|
||||
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
|
||||
assert.equal(terrain.calls.length, 1);
|
||||
assert.equal(terrain.calls[0].length, 4);
|
||||
// Centre of the cell: mean of 100, 101, 102, 103.
|
||||
const height = caster.heightAt(10.0005, 50.0005);
|
||||
assert.ok(Math.abs(height - (101.5 + GROUND_CAST_LIFT_M)) < 1e-6);
|
||||
assert.ok(Math.abs(caster.groundAt(10.0005, 50.0005) - 101.5) < 1e-6);
|
||||
// Same cell again: nothing to fetch.
|
||||
assert.equal(await caster.prepare([[10.0002, 50.0008]]), true);
|
||||
assert.equal(terrain.calls.length, 1);
|
||||
});
|
||||
|
||||
test('castLine returns lon, lat, height triples or null when a corner is missing', async () => {
|
||||
const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 });
|
||||
const line = [
|
||||
[10.0001, 50.0001],
|
||||
[10.0009, 50.0001],
|
||||
];
|
||||
assert.equal(caster.castLine(line), null);
|
||||
await caster.prepareLines([line]);
|
||||
const flat = caster.castLine(line);
|
||||
assert.equal(flat.length % 3, 0);
|
||||
assert.ok(flat.length >= 6);
|
||||
assert.equal(flat[0], 10.0001);
|
||||
assert.equal(flat[1], 50.0001);
|
||||
assert.ok(flat[2] > 100 && flat[2] < 110);
|
||||
});
|
||||
|
||||
test('a geoid fallback or a failing proxy leaves the heights unknown', async () => {
|
||||
const fallback = createGroundCaster({
|
||||
terrain: fakeTerrain({ source: 'geoid-fallback' }),
|
||||
});
|
||||
assert.equal(await fallback.prepare([[10.0005, 50.0005]]), false);
|
||||
assert.equal(fallback.heightAt(10.0005, 50.0005), null);
|
||||
|
||||
const down = createGroundCaster({ terrain: fakeTerrain({ fail: true }) });
|
||||
assert.equal(await down.prepare([[10.0005, 50.0005]]), false);
|
||||
assert.equal(down.heightAt(10.0005, 50.0005), null);
|
||||
});
|
||||
|
||||
test('too many corners are not requested, and aborted prepares do nothing', async () => {
|
||||
const terrain = fakeTerrain();
|
||||
const caster = createGroundCaster({ terrain, step: 0.001, maxCorners: 8 });
|
||||
const wide = [];
|
||||
for (let i = 0; i < 10; i++) wide.push([10 + i * 0.001 + 0.0005, 50.0005]);
|
||||
assert.equal(await caster.prepare(wide), false);
|
||||
assert.equal(terrain.calls.length, 0);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
assert.equal(
|
||||
await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal }),
|
||||
false,
|
||||
);
|
||||
assert.equal(terrain.calls.length, 0);
|
||||
});
|
||||
|
||||
test('prepares run one at a time so neighbours share corners', async () => {
|
||||
const terrain = fakeTerrain();
|
||||
const caster = createGroundCaster({ terrain, step: 0.001 });
|
||||
const [a, b] = await Promise.all([
|
||||
caster.prepare([[10.0005, 50.0005]]),
|
||||
caster.prepare([[10.0015, 50.0005]]),
|
||||
]);
|
||||
assert.equal(a, true);
|
||||
assert.equal(b, true);
|
||||
// The second cell shares its west corners with the first.
|
||||
assert.equal(terrain.calls[1].length, 2);
|
||||
});
|
||||
|
||||
test('the caster needs a terrain service', () => {
|
||||
assert.throws(() => createGroundCaster({ terrain: null }), TypeError);
|
||||
});
|
||||
@@ -9,7 +9,11 @@ import { requiresKeyIdFor, validateProviders } from './registry.js';
|
||||
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||
import { decodeParams, encodeParams } from './params.js';
|
||||
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||
import { viewCentre } from './view.js';
|
||||
import { cameraHeightAboveGround, viewCentre } from './view.js';
|
||||
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
|
||||
|
||||
/** Above this camera height the ground under the camera is not worth fetching. */
|
||||
const SURFACE_WARM_BELOW_M = 6000;
|
||||
import {
|
||||
FOLLOW_MAP_STACK_ID,
|
||||
NEAREST_RADIUS_M,
|
||||
@@ -35,6 +39,9 @@ export function createStreetLevelLayer({
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, parts };
|
||||
parts.groundCaster = services.terrain?.resolveEllipsoidalGround
|
||||
? createGroundCaster({ terrain: services.terrain })
|
||||
: null;
|
||||
parts.credits = createCredits();
|
||||
parts.marker = createMarker(context);
|
||||
parts.follow = createCameraFollow(context);
|
||||
@@ -83,6 +90,10 @@ export function createStreetLevelLayer({
|
||||
getViewer: () => state.viewer,
|
||||
getFilter: () => resolveFilter(state.filter),
|
||||
isActive: () => state.enabled && entry.on,
|
||||
/** Bare-earth heights (see groundCast.js), or null without a terrain service. */
|
||||
groundCaster: parts.groundCaster,
|
||||
/** 'terrain' when overlays should sit on the bare earth, else 'draped'. */
|
||||
getSurface: () => state.surface,
|
||||
notify,
|
||||
actions: {
|
||||
openImage: (imageId) => openImage(entry.def.id, imageId),
|
||||
@@ -112,6 +123,75 @@ export function createStreetLevelLayer({
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera height above the bare earth under it, for the surface mode. The
|
||||
* ground comes from the caster's coarse grid; a cold cell is fetched once
|
||||
* and the mode checked again when it lands.
|
||||
*/
|
||||
function cameraHeightForSurface() {
|
||||
const carto = state.viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
const fallback = cameraHeightAboveGround(state.viewer);
|
||||
if (!(fallback < SURFACE_WARM_BELOW_M)) return fallback;
|
||||
const lon = (carto.longitude * 180) / Math.PI;
|
||||
const lat = (carto.latitude * 180) / Math.PI;
|
||||
const ground = parts.groundCaster.heightAt(lon, lat);
|
||||
if (ground !== null) return carto.height - ground;
|
||||
parts.groundCaster.prepare([[lon, lat]]).then((ready) => {
|
||||
if (ready) scheduleSurfaceSync();
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* On Google 3D at street zoom, overlays are cast to the bare earth; draped
|
||||
* lines would land on roofs and trees. Elsewhere they stay draped.
|
||||
*/
|
||||
function syncSurface() {
|
||||
const available = Boolean(parts.groundCaster) && state.enabled;
|
||||
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
|
||||
const mode = nextSurfaceMode(state.surface, {
|
||||
available,
|
||||
photoreal,
|
||||
heightM: available && photoreal ? cameraHeightForSurface() : null,
|
||||
});
|
||||
if (mode === state.surface) return;
|
||||
state.surface = mode;
|
||||
parts.marker.setSurface(mode);
|
||||
for (const entry of state.providers.values())
|
||||
entry.instance.setSurface?.(mode);
|
||||
notify();
|
||||
}
|
||||
|
||||
let surfaceTimer = null;
|
||||
function scheduleSurfaceSync() {
|
||||
clearTimeout(surfaceTimer);
|
||||
surfaceTimer = setTimeout(syncSurface, 150);
|
||||
}
|
||||
|
||||
let removeSurfaceListeners = null;
|
||||
function watchSurface(viewer) {
|
||||
removeSurfaceListeners?.();
|
||||
const camera = viewer?.camera;
|
||||
if (!camera?.changed || !camera?.moveEnd) {
|
||||
removeSurfaceListeners = null;
|
||||
return;
|
||||
}
|
||||
const removeChanged = camera.changed.addEventListener(scheduleSurfaceSync);
|
||||
const removeEnd = camera.moveEnd.addEventListener(scheduleSurfaceSync);
|
||||
removeSurfaceListeners = () => {
|
||||
removeChanged();
|
||||
removeEnd();
|
||||
};
|
||||
}
|
||||
|
||||
function unwatchSurface() {
|
||||
removeSurfaceListeners?.();
|
||||
removeSurfaceListeners = null;
|
||||
clearTimeout(surfaceTimer);
|
||||
surfaceTimer = null;
|
||||
}
|
||||
|
||||
const activeEntries = () =>
|
||||
[...state.providers.values()].filter((entry) => entry.on);
|
||||
|
||||
@@ -213,6 +293,7 @@ export function createStreetLevelLayer({
|
||||
providers: providerSnapshots(),
|
||||
street,
|
||||
sequence: sequenceSnapshot(),
|
||||
surface: state.surface,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -247,13 +328,17 @@ export function createStreetLevelLayer({
|
||||
parts.marker.setVisible(true);
|
||||
parts.selection.install(viewer);
|
||||
for (const entry of activeEntries()) activate(entry);
|
||||
watchSurface(viewer);
|
||||
syncSurface();
|
||||
notify();
|
||||
},
|
||||
|
||||
disable() {
|
||||
state.enabled = false;
|
||||
unwatchSurface();
|
||||
parts.viewerHost.unmount();
|
||||
for (const entry of state.providers.values()) entry.instance.deactivate();
|
||||
syncSurface();
|
||||
parts.credits.hideAll(state.viewer);
|
||||
parts.selection.uninstall();
|
||||
parts.marker.setVisible(false);
|
||||
@@ -300,8 +385,12 @@ export function createStreetLevelLayer({
|
||||
unsubscribeMapStack?.();
|
||||
mapStack = controller || null;
|
||||
unsubscribeMapStack =
|
||||
mapStack?.subscribe?.(() => syncFollowAvailability()) || null;
|
||||
mapStack?.subscribe?.(() => {
|
||||
syncFollowAvailability();
|
||||
syncSurface();
|
||||
}) || null;
|
||||
syncFollowAvailability();
|
||||
syncSurface();
|
||||
},
|
||||
|
||||
/** Share-link and stored state: provider switches plus the filter. */
|
||||
|
||||
@@ -4,9 +4,14 @@ import { COLORS, POSITION_PICK_ID } from './policy.js';
|
||||
|
||||
const SPRITE_ID = 'street-level:marker';
|
||||
|
||||
/** The on-globe marker for the image the viewer currently shows. */
|
||||
export function createMarker({ state }) {
|
||||
/**
|
||||
* The on-globe marker for the image the viewer currently shows. In terrain
|
||||
* mode (Google 3D at street zoom) it stands on the bare earth instead of
|
||||
* clamping to the top of the photoreal mesh.
|
||||
*/
|
||||
export function createMarker({ state, parts }) {
|
||||
const { render, sprites } = state.services;
|
||||
let last = null;
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('street-level-marker');
|
||||
@@ -21,17 +26,39 @@ export function createMarker({ state }) {
|
||||
sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection);
|
||||
}
|
||||
|
||||
/** Terrain height for the position, or null to clamp; fetches a missing cell. */
|
||||
function castHeight(position) {
|
||||
const ground = parts?.groundCaster;
|
||||
if (state.surface !== 'terrain' || !ground) return null;
|
||||
const height = ground.heightAt(position.lon, position.lat);
|
||||
if (height !== null) return height;
|
||||
ground.prepare([[position.lon, position.lat]]).then((ready) => {
|
||||
if (ready && last?.position === position) set(position, last.bearing);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Move (or create) the marker; a null position removes it. */
|
||||
function set(position, bearing) {
|
||||
const collection = state.marker.collection;
|
||||
if (!collection) return;
|
||||
last = position ? { position, bearing } : null;
|
||||
if (!position) {
|
||||
collection.removeAll();
|
||||
state.marker.billboard = null;
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat);
|
||||
const height = castHeight(position);
|
||||
const cartesian = Cesium.Cartesian3.fromDegrees(
|
||||
position.lon,
|
||||
position.lat,
|
||||
height ?? 0,
|
||||
);
|
||||
const heightReference =
|
||||
height === null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE;
|
||||
if (!state.marker.billboard) {
|
||||
state.marker.billboard = collection.add({
|
||||
id: POSITION_PICK_ID,
|
||||
@@ -41,11 +68,14 @@ export function createMarker({ state }) {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
heightReference,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
});
|
||||
} else state.marker.billboard.position = cartesian;
|
||||
} else {
|
||||
state.marker.billboard.heightReference = heightReference;
|
||||
state.marker.billboard.position = cartesian;
|
||||
}
|
||||
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
|
||||
requestRender();
|
||||
}
|
||||
@@ -64,5 +94,17 @@ export function createMarker({ state }) {
|
||||
state.marker.billboard = null;
|
||||
}
|
||||
|
||||
return { ensure, set, clear: () => set(null), setVisible, destroy };
|
||||
/** Re-place the marker after the surface mode changes. */
|
||||
function setSurface() {
|
||||
if (last) set(last.position, last.bearing);
|
||||
}
|
||||
|
||||
return {
|
||||
ensure,
|
||||
set,
|
||||
clear: () => set(null),
|
||||
setVisible,
|
||||
setSurface,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
PICK_PREFIX,
|
||||
} from './policy.js';
|
||||
|
||||
/** Draped lines stay at most this long while a tile's cast lines build. */
|
||||
const SWAP_MAX_WAIT_MS = 4000;
|
||||
/** Old-zoom tiles are kept at most this long after a zoom change. */
|
||||
const STALE_TILE_MAX_MS = 6000;
|
||||
/**
|
||||
@@ -47,6 +49,11 @@ function sequenceColor(sequence, { selected = false, now = Date.now() } = {}) {
|
||||
* Camera-driven coverage: z0–5 `overview` points from orbit down to 60 km,
|
||||
* then z11–14 sequence polylines clamped to terrain and 3D tiles. Decoded
|
||||
* tiles are kept so the imagery filter can rebuild without refetching.
|
||||
*
|
||||
* In the core's terrain surface mode (Google 3D at street zoom) a tile's
|
||||
* lines are cast to the bare earth instead: draped lines would land on roofs
|
||||
* and tree tops. A tile is drawn draped first and swapped for its cast lines
|
||||
* once the terrain heights are in, so coverage never waits on the terrain.
|
||||
*/
|
||||
export function createCoverage({ state, source }) {
|
||||
const { render } = state.services;
|
||||
@@ -68,51 +75,156 @@ export function createCoverage({ state, source }) {
|
||||
return state.filter;
|
||||
}
|
||||
|
||||
/** One or more ground primitives for a tile's sequences, in draw batches. */
|
||||
function terrainMode() {
|
||||
return (
|
||||
state.context.getSurface?.() === 'terrain' &&
|
||||
Boolean(state.context.groundCaster)
|
||||
);
|
||||
}
|
||||
|
||||
function sequenceInstance(sequence, geometry, now) {
|
||||
return new Cesium.GeometryInstance({
|
||||
geometry,
|
||||
id: `${PICK_PREFIX.sequence}${sequence.id}`,
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
sequenceColor(sequence, {
|
||||
now,
|
||||
selected: sequence.id === state.sequence.selectedId,
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Primitives for a tile's sequences, in draw batches: ground primitives
|
||||
* draped on the globe, plus (in terrain mode) plain polylines at the cast
|
||||
* bare-earth heights for every sequence whose heights are cached.
|
||||
*/
|
||||
function buildSequencePrimitives(sequences) {
|
||||
const now = Date.now();
|
||||
const instances = [];
|
||||
const ground = terrainMode() ? state.context.groundCaster : null;
|
||||
const draped = [];
|
||||
const cast = [];
|
||||
for (const sequence of sequences) {
|
||||
if (!passesImageryFilter(sequence, filter())) continue;
|
||||
const flat = [];
|
||||
for (const [lon, lat] of sequence.coordinates) flat.push(lon, lat);
|
||||
const flat = ground?.castLine(sequence.coordinates);
|
||||
let positions;
|
||||
try {
|
||||
positions = Cesium.Cartesian3.fromDegreesArray(flat);
|
||||
positions = flat
|
||||
? Cesium.Cartesian3.fromDegreesArrayHeights(flat)
|
||||
: Cesium.Cartesian3.fromDegreesArray(sequence.coordinates.flat());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (positions.length < 2) continue;
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.GroundPolylineGeometry({
|
||||
positions,
|
||||
width: COVERAGE_LINE_WIDTH_PX,
|
||||
}),
|
||||
id: `${PICK_PREFIX.sequence}${sequence.id}`,
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
sequenceColor(sequence, {
|
||||
now,
|
||||
selected: sequence.id === state.sequence.selectedId,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (flat)
|
||||
cast.push(
|
||||
sequenceInstance(
|
||||
sequence,
|
||||
new Cesium.PolylineGeometry({
|
||||
positions,
|
||||
width: COVERAGE_LINE_WIDTH_PX,
|
||||
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
}),
|
||||
now,
|
||||
),
|
||||
);
|
||||
else
|
||||
draped.push(
|
||||
sequenceInstance(
|
||||
sequence,
|
||||
new Cesium.GroundPolylineGeometry({
|
||||
positions,
|
||||
width: COVERAGE_LINE_WIDTH_PX,
|
||||
}),
|
||||
now,
|
||||
),
|
||||
);
|
||||
}
|
||||
const primitives = [];
|
||||
for (let i = 0; i < instances.length; i += SEQUENCE_PRIMITIVE_BATCH)
|
||||
primitives.push(
|
||||
new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
|
||||
for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH)
|
||||
primitives.push({
|
||||
onGround: true,
|
||||
primitive: new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
|
||||
appearance: new Cesium.PolylineColorAppearance(),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
asynchronous: true,
|
||||
allowPicking: true,
|
||||
}),
|
||||
});
|
||||
for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH)
|
||||
primitives.push({
|
||||
onGround: false,
|
||||
primitive: new Cesium.Primitive({
|
||||
geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
|
||||
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
asynchronous: true,
|
||||
allowPicking: true,
|
||||
}),
|
||||
});
|
||||
return { primitives, count: draped.length + cast.length, draped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the terrain heights a tile's lines need, then redraw the tile cast.
|
||||
* Runs once per tile entry; unresolved lines stay draped.
|
||||
*/
|
||||
function castTile(entry) {
|
||||
if (entry.castRequested || !terrainMode()) return;
|
||||
entry.castRequested = true;
|
||||
entry.castAbort = new AbortController();
|
||||
const { signal } = entry.castAbort;
|
||||
const lines = entry.sequenceList
|
||||
.filter((sequence) => passesImageryFilter(sequence, filter()))
|
||||
.map((sequence) => sequence.coordinates);
|
||||
state.context.groundCaster.prepareLines(lines, { signal }).then(() => {
|
||||
const attached = [...state.coverage.tiles.values()].includes(entry);
|
||||
if (signal.aborted || !attached || !terrainMode()) return;
|
||||
// Keep the draped lines until the cast ones are built, so nothing blinks.
|
||||
const previous = entry.primitives;
|
||||
entry.primitives = [];
|
||||
attachPrimitive(entry);
|
||||
removeWhenReady(entry, previous);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a tile's previous primitives once all its current ones are ready
|
||||
* (or after a few seconds); detaching the tile removes them at once.
|
||||
*/
|
||||
function removeWhenReady(entry, old) {
|
||||
const scene = state.viewer?.scene;
|
||||
finishSwap(entry);
|
||||
if (!scene?.postRender) {
|
||||
removePrimitives(old);
|
||||
return;
|
||||
}
|
||||
const fresh = entry.primitives;
|
||||
const started = Date.now();
|
||||
const stop = scene.postRender.addEventListener(() => {
|
||||
const ready = fresh.every(
|
||||
({ primitive }) => primitive.ready || primitive.isDestroyed?.(),
|
||||
);
|
||||
return { primitives, count: instances.length };
|
||||
if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) {
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
finishSwap(entry);
|
||||
requestRender();
|
||||
});
|
||||
entry.swap = { old, stop };
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function finishSwap(entry) {
|
||||
if (!entry.swap) return;
|
||||
entry.swap.stop();
|
||||
removePrimitives(entry.swap.old);
|
||||
entry.swap = null;
|
||||
}
|
||||
|
||||
function buildOverviewCollection(points) {
|
||||
@@ -141,10 +253,14 @@ export function createCoverage({ state, source }) {
|
||||
const scene = state.viewer?.scene;
|
||||
if (!scene) return;
|
||||
if (entry.kind === 'sequence') {
|
||||
const { primitives, count } = buildSequencePrimitives(entry.sequenceList);
|
||||
const { primitives, count, draped } = buildSequencePrimitives(
|
||||
entry.sequenceList,
|
||||
);
|
||||
entry.primitives = primitives;
|
||||
entry.count = count;
|
||||
for (const primitive of primitives) scene.groundPrimitives.add(primitive);
|
||||
for (const { primitive, onGround } of primitives)
|
||||
(onGround ? scene.groundPrimitives : scene.primitives).add(primitive);
|
||||
if (draped.length) castTile(entry);
|
||||
} else {
|
||||
const { collection, count } = buildOverviewCollection(entry.points);
|
||||
entry.primitive = collection;
|
||||
@@ -153,15 +269,23 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
}
|
||||
|
||||
function detachPrimitive(entry) {
|
||||
function removePrimitives(list) {
|
||||
const scene = state.viewer?.scene;
|
||||
for (const primitive of entry.primitives || []) {
|
||||
for (const { primitive, onGround } of list || []) {
|
||||
try {
|
||||
scene?.groundPrimitives?.remove(primitive);
|
||||
(onGround ? scene?.groundPrimitives : scene?.primitives)?.remove(
|
||||
primitive,
|
||||
);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function detachPrimitive(entry) {
|
||||
const scene = state.viewer?.scene;
|
||||
finishSwap(entry);
|
||||
removePrimitives(entry.primitives);
|
||||
entry.primitives = [];
|
||||
if (entry.primitive) {
|
||||
try {
|
||||
@@ -173,9 +297,16 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop a tile's pending terrain lookup (tile dropped or retired). */
|
||||
function cancelCast(entry) {
|
||||
entry.castAbort?.abort();
|
||||
entry.castAbort = null;
|
||||
}
|
||||
|
||||
function removeTile(key) {
|
||||
const entry = state.coverage.tiles.get(key);
|
||||
if (!entry) return;
|
||||
cancelCast(entry);
|
||||
detachPrimitive(entry);
|
||||
state.coverage.tiles.delete(key);
|
||||
}
|
||||
@@ -254,6 +385,7 @@ export function createCoverage({ state, source }) {
|
||||
state.coverage.pending.clear();
|
||||
state.coverage.loading = 0;
|
||||
for (const [key, entry] of state.coverage.tiles) {
|
||||
cancelCast(entry);
|
||||
const previous = state.coverage.stale.get(key);
|
||||
if (previous) detachPrimitive(previous);
|
||||
state.coverage.stale.set(key, entry);
|
||||
@@ -350,6 +482,8 @@ export function createCoverage({ state, source }) {
|
||||
function rebuild() {
|
||||
purgeStale();
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
cancelCast(entry);
|
||||
entry.castRequested = false;
|
||||
detachPrimitive(entry);
|
||||
attachPrimitive(entry);
|
||||
}
|
||||
@@ -357,6 +491,12 @@ export function createCoverage({ state, source }) {
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Redraw loaded tiles draped or cast after the core's surface mode changes. */
|
||||
function setSurface() {
|
||||
if (!state.context.isActive()) return;
|
||||
rebuild();
|
||||
}
|
||||
|
||||
/** Look a sequence up across loaded tiles. */
|
||||
function findSequence(id) {
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
@@ -372,7 +512,7 @@ export function createCoverage({ state, source }) {
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
const sequence = entry.sequences.get(id);
|
||||
if (!sequence) continue;
|
||||
for (const primitive of entry.primitives || []) {
|
||||
for (const { primitive } of entry.primitives || []) {
|
||||
if (!primitive.ready) continue;
|
||||
try {
|
||||
const attributes =
|
||||
@@ -402,6 +542,7 @@ export function createCoverage({ state, source }) {
|
||||
refresh,
|
||||
clear,
|
||||
rebuild,
|
||||
setSurface,
|
||||
findSequence,
|
||||
recolorSequence,
|
||||
sequenceCount,
|
||||
|
||||
@@ -151,6 +151,12 @@ export function createMapillaryProvider({ source }) {
|
||||
parts.sequences.rerender();
|
||||
},
|
||||
|
||||
/** Core surface mode changed: redraw lines and cones draped or cast. */
|
||||
setSurface() {
|
||||
parts.coverage.setSurface();
|
||||
parts.sequences.rerender();
|
||||
},
|
||||
|
||||
coverageStats() {
|
||||
return {
|
||||
count: parts.coverage.sequenceCount(),
|
||||
|
||||
@@ -77,29 +77,59 @@ export function createSequences({ state, source, parts }) {
|
||||
state.sequence.images = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Terrain heights for the cones in terrain mode (Google 3D at street zoom),
|
||||
* so they stand on the street rather than on roofs and tree tops. Null when
|
||||
* draped, or when some height is not cached yet; that fetches the heights
|
||||
* and draws the cones again.
|
||||
*/
|
||||
function coneHeights(images) {
|
||||
const ground = state.context.groundCaster;
|
||||
if (state.context.getSurface?.() !== 'terrain' || !ground) return null;
|
||||
const heights = images.map((image) =>
|
||||
ground.heightAt(image.lon, image.lat),
|
||||
);
|
||||
if (!heights.includes(null)) return heights;
|
||||
ground
|
||||
.prepare(images.map((image) => [image.lon, image.lat]))
|
||||
.then((ready) => {
|
||||
if (ready && state.sequence.images === images) renderCones(images);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderCones(images) {
|
||||
const collection = state.sequence.collection;
|
||||
if (!collection) return;
|
||||
collection.removeAll();
|
||||
const heights = coneHeights(images);
|
||||
const cone = imageConeGlyph({ size: 32, color: COLORS.image });
|
||||
const ring = imageConeGlyph({ size: 32, color: COLORS.pano, pano: true });
|
||||
for (const image of images) {
|
||||
if (!passesImageryFilter(image, state.filter)) continue;
|
||||
images.forEach((image, index) => {
|
||||
if (!passesImageryFilter(image, state.filter)) return;
|
||||
const height = heights?.[index] ?? null;
|
||||
collection.add({
|
||||
id: `${PICK_PREFIX.image}${image.id}`,
|
||||
position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat),
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
image.lon,
|
||||
image.lat,
|
||||
height ?? 0,
|
||||
),
|
||||
image: image.isPano ? ring : cone,
|
||||
imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone',
|
||||
width: IMAGE_CONE_SIZE_PX,
|
||||
height: IMAGE_CONE_SIZE_PX,
|
||||
rotation: -Cesium.Math.toRadians(image.compassAngle),
|
||||
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
heightReference:
|
||||
height === null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
|
||||
});
|
||||
}
|
||||
});
|
||||
requestRender();
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,12 @@
|
||||
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||
*
|
||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||
* @property {object} services Scene services: picking, input, render, sprites, ground.
|
||||
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain.
|
||||
* @property {() => object|null} getViewer The Cesium viewer once the layer is initialised.
|
||||
* @property {() => {pano: string, sinceMs: number|null}} getFilter The resolved imagery filter.
|
||||
* @property {() => boolean} isActive Layer enabled and this provider switched on.
|
||||
* @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on the bare earth, not the mesh top.
|
||||
* @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service.
|
||||
* @property {() => void} notify Ask the core to publish a new UI snapshot.
|
||||
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
|
||||
*
|
||||
@@ -30,6 +32,7 @@
|
||||
* @property {(viewer: object) => void} destroy
|
||||
* @property {() => void} refreshCoverage
|
||||
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter
|
||||
* @property {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode.
|
||||
* @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats
|
||||
* @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix.
|
||||
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
|
||||
@@ -38,6 +41,13 @@
|
||||
* @property {(point: {lat: number, lon: number}) => Promise<string|null>} nearestImage
|
||||
* @property {ViewerAdapter} viewer
|
||||
*
|
||||
* @typedef {object} GroundCaster
|
||||
* @property {(points: Array<[number, number]>, options?: {signal?: AbortSignal}) => Promise<boolean>} prepare Fetch the heights around [lon, lat] points; true when all are known.
|
||||
* @property {(lines: Array<Array<[number, number]>>, options?: {signal?: AbortSignal}) => Promise<boolean>} prepareLines
|
||||
* @property {(lon: number, lat: number) => number|null} groundAt Cached bare-earth ellipsoidal height.
|
||||
* @property {(lon: number, lat: number) => number|null} heightAt groundAt plus the overlay lift.
|
||||
* @property {(coords: Array<[number, number]>) => Array<number>|null} castLine Densified [lon, lat, height, ...] or null.
|
||||
*
|
||||
* @typedef {object} ViewerAdapter
|
||||
* @property {(host: HTMLElement) => Promise<void>} mount Idempotent; may lazy-load a library.
|
||||
* @property {(imageId: string) => Promise<void>} open Resolves once the image is on screen and a pose was emitted.
|
||||
|
||||
@@ -40,6 +40,9 @@ export function createState({ services }) {
|
||||
renderMode: 'letterbox',
|
||||
},
|
||||
|
||||
/** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
|
||||
surface: 'draped',
|
||||
|
||||
marker: { collection: null, billboard: null },
|
||||
clickHandler: null,
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ export function composeUIState({
|
||||
providers,
|
||||
street,
|
||||
sequence,
|
||||
surface = 'draped',
|
||||
}) {
|
||||
const active = providers.filter((p) => p.on);
|
||||
const { keyRequired, ...coverage } = summarizeCoverage(providers);
|
||||
@@ -73,5 +74,6 @@ export function composeUIState({
|
||||
legend,
|
||||
sequence: { ...sequence },
|
||||
street: { ...street },
|
||||
surface,
|
||||
};
|
||||
}
|
||||
@@ -120,3 +120,15 @@ test('summarizeCoverage is what getStats reports, without building a snapshot',
|
||||
);
|
||||
assert.equal(summarizeCoverage([]).keyRequired, false);
|
||||
});
|
||||
|
||||
test('the surface mode defaults to draped and passes through', () => {
|
||||
assert.equal(
|
||||
composeUIState({ ...base, providers: [provider()] }).surface,
|
||||
'draped',
|
||||
);
|
||||
assert.equal(
|
||||
composeUIState({ ...base, providers: [provider()], surface: 'terrain' })
|
||||
.surface,
|
||||
'terrain',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user