mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
docs(street-level): shorten docs and comments before merge
Trim the doc text this PR adds (README, CHANGELOG, CURRENT-STATE, KNOWN-ISSUES, SECURITY, DATA_SOURCES, .env.example, the CI job comment), drop code comments that restate the code, cut design-note JSDoc to a line or two, and drop review tags from test names. Comments only; no code changes. Also: README key count (seven keys, two metered), CURRENT-STATE's gate paragraph (CI runs a production build with a dummy token), the stacked doc above meshBoxInRange and the orphaned inMeshRange line in coverage.js, and a duplicate ALPR row in DATA_SOURCES that a merge had brought back. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c65feda4b1
commit
4ea6631425
85 files changed
+701
-1520
No files matched your search
@@ -5,11 +5,7 @@ import * as picking from '../../data/pickRegistry.js';
|
||||
import * as input from '../../data/inputOwnership.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
|
||||
/**
|
||||
* Construct the Street Level layer with every imagery provider this build
|
||||
* ships, using the application scene owners and the supplied sources. A new
|
||||
* provider registers here; its chip, credit and share bit follow.
|
||||
*/
|
||||
/** A new provider registers here; its chip, credit and share bit follow. */
|
||||
export function createApplicationStreetLevel({ surface, sources }) {
|
||||
return createStreetLevelLayer({
|
||||
providers: [createMapillaryProvider({ source: sources.mapillary })],
|
||||
|
||||
@@ -418,9 +418,8 @@ const OPTION_GROUPS = Object.freeze({
|
||||
pano: 'p',
|
||||
flat: 'f',
|
||||
}),
|
||||
// "Captured since" as relative days, so a link means the same next year.
|
||||
// The filter's MAX_SINCE_DAYS (streetLevel/policy.js); layerState.test
|
||||
// pins the two together.
|
||||
// Relative days, so a link means the same next year. The max matches
|
||||
// MAX_SINCE_DAYS in streetLevel/policy.js (pinned by layerState.test).
|
||||
boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }),
|
||||
]),
|
||||
radio: Object.freeze([
|
||||
|
||||
@@ -2678,8 +2678,7 @@ test('Street Level: every option round-trips through a share link and stored sta
|
||||
});
|
||||
|
||||
test('Street Level: the link codec accepts exactly the windows the filter keeps', () => {
|
||||
// The codec's ceiling is the filter's MAX_SINCE_DAYS: a longer window is
|
||||
// rejected (read as "any date"), the longest one is kept whole.
|
||||
// A window past MAX_SINCE_DAYS reads as "any date".
|
||||
assert.equal(
|
||||
streetLevelOptions(`0.s.${MAX_SINCE_DAYS}`).sinceDays,
|
||||
MAX_SINCE_DAYS,
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
|
||||
|
||||
/** Ellipsoidal heights a land surface can have (Dead Sea shore to Everest, with geoid slack). */
|
||||
/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */
|
||||
const SURFACE_MIN_M = -500;
|
||||
const SURFACE_MAX_M = 9000;
|
||||
/** Rendered mesh window around the bare earth: roofs and bridges, not whole towers. */
|
||||
const MESH_BELOW_DEM_M = 15;
|
||||
const MESH_ABOVE_DEM_M = 80;
|
||||
|
||||
/**
|
||||
* Drive the globe camera from the street-level pose: either continuously
|
||||
* ("camera follows view") or once, framing the image from behind.
|
||||
*/
|
||||
/** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */
|
||||
export function createCameraFollow({ state, parts }) {
|
||||
const { render } = state.services;
|
||||
|
||||
@@ -20,11 +17,9 @@ export function createCameraFollow({ state, parts }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground under a photo for framing and following. The rendered surface is
|
||||
* sampled first, but `sampleHeight` can return nonsense before the tiles
|
||||
* under the point have loaded (kilometres below the ellipsoid), so a sample
|
||||
* counts only when it is a plausible surface height and, once the bare-earth
|
||||
* height is known, lies within the mesh window around it.
|
||||
* Ground under a photo. `sampleHeight` can return kilometres below the
|
||||
* ellipsoid before tiles load, so a sample counts only when plausible and
|
||||
* within the mesh window around bare earth (once that is known).
|
||||
*/
|
||||
function groundHeightAt(lon, lat, fallback) {
|
||||
const scene = state.viewer?.scene;
|
||||
@@ -70,12 +65,7 @@ export function createCameraFollow({ state, parts }) {
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* The framing flight this module started, while it is still the camera's
|
||||
* current flight. Cesium cancels it (calling `cancel`) when any other
|
||||
* flight starts, so a stale token never cancels someone else's flight.
|
||||
* @type {object|null}
|
||||
*/
|
||||
/** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */
|
||||
let framing = null;
|
||||
|
||||
/** Frame the current image from a short distance behind it. */
|
||||
@@ -108,10 +98,7 @@ export function createCameraFollow({ state, parts }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the framing flight if it is still in the air (the image closed or
|
||||
* the layer went off). A flight another feature started since is left be.
|
||||
*/
|
||||
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
|
||||
function cancelFraming() {
|
||||
if (!framing) return;
|
||||
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
|
||||
|
||||
@@ -9,10 +9,8 @@ const heightOf = (cartesian) =>
|
||||
Cesium.Cartographic.fromCartesian(cartesian).height;
|
||||
|
||||
/**
|
||||
* A camera with Cesium's flight bookkeeping: starting a flight cancels the
|
||||
* current one (calling its `cancel`), `cancelFlight` cancels whichever flight
|
||||
* is current, whoever started it, and `land()` completes it. `cancelled`
|
||||
* counts the flights `cancelFlight` stopped.
|
||||
* A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight`
|
||||
* cancels the current one, `land()` completes it, `cancelled` counts stops.
|
||||
*/
|
||||
function flightCamera(flights, views) {
|
||||
let current = null;
|
||||
@@ -43,8 +41,8 @@ function flightCamera(flights, views) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A viewer whose scene sample is `sampled` (the Austin case: −14,886 m before
|
||||
* the tiles under the photo had loaded) and whose bare earth is `dem`.
|
||||
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
|
||||
* under the photo load) and whose bare earth is `dem`.
|
||||
*/
|
||||
function setup({ sampled, dem = null, globe = null, altitude = 149 }) {
|
||||
const flights = [];
|
||||
@@ -118,7 +116,7 @@ test('following stands the camera at eye height above the checked ground', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('cancelFraming stops the framing flight while it is still ours (review IC8 P1)', () => {
|
||||
test('cancelFraming stops the framing flight while it is still ours', () => {
|
||||
const { follow, state } = setup({ sampled: 121, dem: 117 });
|
||||
const { camera } = state.viewer;
|
||||
follow.lookAtPosition();
|
||||
@@ -134,7 +132,7 @@ test('cancelFraming stops the framing flight while it is still ours (review IC8
|
||||
assert.equal(camera.cancelled, 2);
|
||||
});
|
||||
|
||||
test('cancelFraming leaves a landed flight and a newer navigation flight alone (review IC8 P1)', () => {
|
||||
test('cancelFraming leaves a landed flight and a newer navigation flight alone', () => {
|
||||
const { follow, state, flights } = setup({ sampled: 121, dem: 117 });
|
||||
const { camera } = state.viewer;
|
||||
follow.lookAtPosition();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* One on-screen Cesium credit per active provider. Street-level imagery is
|
||||
* typically CC BY-SA, which needs visible attribution that goes away with
|
||||
* the imagery, so these are static credits added and removed per provider.
|
||||
* One static Cesium credit per active provider: CC BY-SA imagery needs
|
||||
* visible attribution that goes away with the imagery.
|
||||
*/
|
||||
export function createCredits() {
|
||||
/** @type {Map<string, object>} provider id → Cesium.Credit */
|
||||
|
||||
@@ -3,8 +3,8 @@ import { FILTER_DEFAULT, MAX_SINCE_DAYS, PANO_MODES } from './policy.js';
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Merge a partial filter change into the current one. Unknown panorama modes
|
||||
* and negative or non-integer day counts leave the current value alone.
|
||||
* Merge a partial filter change; unknown pano modes and invalid day counts
|
||||
* keep the current value.
|
||||
* @param {{pano?: string, sinceDays?: number}|null} next
|
||||
* @param {{pano: string, sinceDays: number}} [current]
|
||||
* @returns {{pano: string, sinceDays: number}}
|
||||
@@ -26,8 +26,8 @@ export function sameFilter(a, b) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the stored filter (relative days, stable in share links) into the
|
||||
* absolute form providers compare capture times against.
|
||||
* Stored filter (relative days, stable in share links) to the absolute form
|
||||
* providers compare capture times against.
|
||||
* @returns {{pano: string, sinceMs: number|null}}
|
||||
*/
|
||||
export function resolveFilter(filter, now = Date.now()) {
|
||||
@@ -39,8 +39,8 @@ export function resolveFilter(filter, now = Date.now()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one capture passes the resolved imagery filter: panorama mode
|
||||
* ('all' | 'pano' | 'flat') and an optional earliest capture time.
|
||||
* Whether a capture passes the resolved filter: pano mode and an optional
|
||||
* earliest capture time.
|
||||
* @param {{isPano?: boolean, capturedAt?: number}} record
|
||||
* @param {{pano: string, sinceMs: number|null}|null} filter
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* Canvas glyphs for billboards: a camera cone for images, a ring for
|
||||
* panoramas and a position marker for the street-level viewer. Pure canvas
|
||||
* drawing so it can run without Cesium.
|
||||
*/
|
||||
/** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */
|
||||
|
||||
const _cache = new Map();
|
||||
|
||||
@@ -29,7 +25,6 @@ function wedge(ctx, c, radius, halfAngle, color, alpha) {
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/** A stroked circle around the centre. */
|
||||
function ring(ctx, c, radius, color, width, alpha) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(c, c, radius, 0, Math.PI * 2);
|
||||
@@ -40,7 +35,6 @@ function ring(ctx, c, radius, color, width, alpha) {
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/** A filled dot at the centre with a dark outline. */
|
||||
function dot(ctx, c, radius, color, outline, width) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(c, c, radius, 0, Math.PI * 2);
|
||||
@@ -71,7 +65,7 @@ export function imageConeGlyph({
|
||||
});
|
||||
}
|
||||
|
||||
/** Pulsing-looking marker for the image the viewer currently shows. */
|
||||
/** Marker for the image the viewer currently shows. */
|
||||
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
|
||||
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
|
||||
wedge(ctx, c, size * 0.48, 0.55, color, 0.55);
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
/**
|
||||
* 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.
|
||||
* Ground casting for street-level overlays on Google 3D. Draped overlays land
|
||||
* on the mesh top (trees, overpasses), so casting places them at bare-earth
|
||||
* (DEM) height plus a lift and lets the mesh hide what is behind buildings.
|
||||
* Heights come from a coarse grid shared with the ground-floor service; a
|
||||
* geoid fallback (terrain proxy down) counts as unknown and stays draped.
|
||||
*/
|
||||
|
||||
import { wrapLon } from './tileMath.js';
|
||||
@@ -28,15 +20,12 @@ export const GROUND_CAST_MAX_CORNERS = 1024;
|
||||
const GROUND_CAST_CACHE_MAX = 50_000;
|
||||
|
||||
/**
|
||||
* Mesh refinement. The bare-earth grid cannot see a freeway in a trench, a
|
||||
* steep street between grid corners, or the road under a tree, so where the
|
||||
* rendered Google 3D surface has been sampled it refines the height:
|
||||
* - mesh at most MESH_ROAD_ABOVE_M above bare earth (and not absurdly far
|
||||
* below it) is the road itself: follow it, MESH_LIFT_M above;
|
||||
* - mesh higher than that is something over the road (canopy, deck, roof):
|
||||
* carry the road's offset from bare earth across from the nearest road
|
||||
* samples on both sides, within MESH_BRIDGE_MAX_POINTS;
|
||||
* - otherwise bare earth plus GROUND_CAST_LIFT_M, as without the mesh.
|
||||
* Mesh refinement, where the Google 3D surface was sampled (the DEM grid
|
||||
* misses trenches, steep streets and roads under trees):
|
||||
* - mesh up to MESH_ROAD_ABOVE_M above bare earth is the road: follow it;
|
||||
* - higher mesh is over the road (canopy, deck): interpolate the road's
|
||||
* offset from the nearest road samples on both sides;
|
||||
* - otherwise bare earth plus GROUND_CAST_LIFT_M.
|
||||
*/
|
||||
export const MESH_ROAD_ABOVE_M = 3;
|
||||
/** A sample this far below bare earth is a bad probe, not a road. */
|
||||
@@ -49,8 +38,7 @@ export const MESH_BRIDGE_MAX_POINTS = 8;
|
||||
export const MESH_DENSIFY_DEG = 0.0002;
|
||||
|
||||
/**
|
||||
* Heights for a line's points from bare earth and, where sampled, the mesh.
|
||||
* Pure, so the rule above is testable without a scene.
|
||||
* Heights for a line's points by the rule above.
|
||||
* @param {Array<{dem: number, mesh?: number|null}>} points
|
||||
* @param {{lift?: number}} [options]
|
||||
* @returns {Array<number>}
|
||||
@@ -93,9 +81,8 @@ export const SURFACE_TERRAIN_ENTER_M = 1400;
|
||||
export const SURFACE_TERRAIN_EXIT_M = 1800;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Next surface mode: terrain only on Google 3D at street zoom, with
|
||||
* hysteresis between the enter and exit heights.
|
||||
* @param {'draped'|'terrain'} current
|
||||
* @param {{photoreal: boolean, heightM: number|null, available: boolean}} view
|
||||
* @returns {'draped'|'terrain'}
|
||||
@@ -108,9 +95,8 @@ export function nextSurfaceMode(current, { photoreal, heightM, available }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Split long segments so a line follows the terrain between its vertices.
|
||||
* A segment across ±180° is split the short way round, and the points it
|
||||
* adds are wrapped back into [-180, 180].
|
||||
* Split long segments so a line follows the terrain. Segments across ±180°
|
||||
* go the short way round, with added points wrapped into [-180, 180].
|
||||
* @param {Array<[number, number]>} coords [lon, lat] pairs
|
||||
* @param {number} [maxStep] longest segment, in degrees
|
||||
* @returns {Array<[number, number]>}
|
||||
@@ -172,9 +158,8 @@ export function createGroundCaster({
|
||||
|
||||
async function resolve(points, signal) {
|
||||
if (signal?.aborted) return false;
|
||||
// Make room first: evicting after counting would drop corners this
|
||||
// request already treated as cached. Oldest first (a Map keeps insertion
|
||||
// order), so the tiles cast last, likely still on screen, keep theirs.
|
||||
// Evict before counting, or we could drop corners this request counted
|
||||
// as cached. Oldest first, so the tiles still on screen keep theirs.
|
||||
for (const key of heights.keys()) {
|
||||
if (heights.size + maxCorners <= cacheMax) break;
|
||||
heights.delete(key);
|
||||
@@ -182,8 +167,7 @@ export function createGroundCaster({
|
||||
const missing = missingCorners(points);
|
||||
if (!missing.length) return true;
|
||||
if (missing.length > maxCorners) return false;
|
||||
// A corner east of 180° (the far corner of a point on the meridian) is
|
||||
// asked for at its longitude on the other side.
|
||||
// A corner east of 180° is asked for at its wrapped longitude.
|
||||
const results = await terrain.resolveEllipsoidalGround(
|
||||
missing.map(([, { i, j }]) => ({
|
||||
lon: Number(wrapLon(i * step).toFixed(6)),
|
||||
@@ -202,9 +186,8 @@ export function createGroundCaster({
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Fetch the grid corners around the points. True when all can be cast;
|
||||
* false when some are unknown, too many, or aborted. Never rejects.
|
||||
* @param {Array<[number, number]>} points [lon, lat] pairs
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
*/
|
||||
@@ -215,10 +198,8 @@ export function createGroundCaster({
|
||||
}
|
||||
|
||||
/**
|
||||
* `prepare` for lines, including the points densifying adds. `castLine`
|
||||
* densifies finer when mesh heights refine it, and those points can fall in
|
||||
* grid cells the coarse points skip, so both sets are prepared: otherwise
|
||||
* the cast comes back null and the line stays draped over roofs and trees.
|
||||
* `prepare` for lines at both densities: the finer mesh points can fall in
|
||||
* cells the coarse ones skip, and a missing cell leaves the line draped.
|
||||
*/
|
||||
function prepareLines(lines, options) {
|
||||
return prepare(
|
||||
@@ -250,10 +231,8 @@ export function createGroundCaster({
|
||||
}
|
||||
|
||||
/**
|
||||
* A line as flat [lon, lat, height, ...] degrees and metres, densified;
|
||||
* null when any corner it needs is not cached yet. With `meshAt` (sampled
|
||||
* mesh heights, undefined or null where unknown) the line is densified
|
||||
* finer and its heights refined by `refineHeights`.
|
||||
* Densified line as flat [lon, lat, height, ...]; null until every corner is
|
||||
* cached. With `meshAt` it is densified finer and refined by `refineHeights`.
|
||||
* @param {Array<[number, number]>} coords
|
||||
* @param {{meshAt?: (lon: number, lat: number) => number|null|undefined}} [options]
|
||||
*/
|
||||
|
||||
@@ -83,7 +83,7 @@ test('densifyLine splits long segments and keeps the vertices', () => {
|
||||
assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]);
|
||||
});
|
||||
|
||||
test('densifyLine crosses the date line the short way and wraps what it adds (review IC8 P2)', () => {
|
||||
test('densifyLine crosses the date line the short way and wraps what it adds', () => {
|
||||
// 0.0002° apart: short enough to stay one segment, not 65 points via 0°.
|
||||
const short = [
|
||||
[179.9999, 0],
|
||||
@@ -257,8 +257,8 @@ test('a full cache is cleared before a request counts its corners, so the reques
|
||||
});
|
||||
// One cell's four corners are cached...
|
||||
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
|
||||
// ...then a line over that cell and the next needs two more. Clearing after
|
||||
// counting would have kept only the two new corners and lost the first cell.
|
||||
// ...then a line over that cell and the next needs two more, and the first
|
||||
// cell must survive the eviction.
|
||||
const line = [
|
||||
[10.0002, 50.0005],
|
||||
[10.0018, 50.0005],
|
||||
@@ -304,10 +304,9 @@ test('cancelling a prepare cancels its terrain request', async () => {
|
||||
assert.equal(seen[0], controller.signal, 'the tile signal reaches the proxy');
|
||||
});
|
||||
|
||||
test('prepared lines always cast with the mesh, however their segments cross the grid (review IC8 P1)', async () => {
|
||||
// Seeded walks with 50–300 m segments: the finer mesh densifying used to
|
||||
// land in grid cells the coarse points skipped, so most of them came back
|
||||
// null and stayed draped over roofs.
|
||||
test('prepared lines always cast with the mesh, however their segments cross the grid', async () => {
|
||||
// Seeded walks with 50–300 m segments: the finer mesh points can land in
|
||||
// grid cells the coarse points skip.
|
||||
let seed = 7;
|
||||
const random = () => {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
@@ -332,7 +331,7 @@ test('prepared lines always cast with the mesh, however their segments cross the
|
||||
assert.equal(failures, 0);
|
||||
});
|
||||
|
||||
test('a line across the date line casts along the date line (review IC8 P2)', async () => {
|
||||
test('a line across the date line casts along the date line', async () => {
|
||||
// Ground that rises northward only, so both sides of ±180° agree.
|
||||
const calls = [];
|
||||
const terrain = {
|
||||
|
||||
@@ -25,11 +25,8 @@ import {
|
||||
export { STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||
|
||||
/**
|
||||
* Construct the Street Level layer from a list of imagery providers (see
|
||||
* registry.js for the contract). The core owns what every provider shares:
|
||||
* the enable state and per-provider switches, the imagery filter, one click
|
||||
* handler, one viewer host, the position marker, camera follow, credits and
|
||||
* share-link parameters. Providers own their coverage, sequences and viewer.
|
||||
* The Street Level layer over imagery providers (contract in registry.js).
|
||||
* The core owns what they share; providers own coverage, sequences, viewer.
|
||||
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
|
||||
*/
|
||||
export function createStreetLevelLayer({
|
||||
@@ -84,23 +81,17 @@ export function createStreetLevelLayer({
|
||||
function providerContext(entry) {
|
||||
return Object.freeze({
|
||||
services: state.services,
|
||||
/** The imagery filter resolved against now; read it when filtering. */
|
||||
getFilter: () => resolveFilter(state.filter),
|
||||
isActive: () => state.enabled && entry.on,
|
||||
/** Bare-earth heights (see groundCast.js), or null without a terrain service. */
|
||||
groundCaster: parts.groundCaster,
|
||||
/** Sampled Google 3D surface heights refining the cast (meshSampler.js), or null. */
|
||||
meshSampler: parts.meshSampler,
|
||||
/** 'terrain' when overlays should sit on the bare earth, else 'draped'. */
|
||||
getSurface: () => state.surface,
|
||||
notify,
|
||||
actions: {
|
||||
openImage: (imageId) => openImage(entry.def.id, imageId),
|
||||
/**
|
||||
* Show a provider error, or (null) withdraw the one it showed. A
|
||||
* withdrawal never clears an error the viewer or another provider
|
||||
* has shown since, and a stale one cannot mask a later coverage
|
||||
* error such as KEY REJECTED.
|
||||
* Show a provider error, or withdraw it (null). A withdrawal clears
|
||||
* only this provider's own error, never a newer one from elsewhere.
|
||||
*/
|
||||
reportError: (message) => {
|
||||
if (message) {
|
||||
@@ -143,9 +134,8 @@ export function createStreetLevelLayer({
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera height above the bare earth under it, for the surface mode. The
|
||||
* ground comes from the caster's coarse grid; a cold cell near enough to
|
||||
* matter is fetched once and the mode checked again when it lands.
|
||||
* Camera height above bare earth, for the surface mode. A cold grid cell is
|
||||
* fetched once and the mode rechecked when it lands.
|
||||
*/
|
||||
function cameraHeightForSurface() {
|
||||
const carto = state.viewer?.camera?.positionCartographic;
|
||||
@@ -163,10 +153,7 @@ export function createStreetLevelLayer({
|
||||
return height;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
|
||||
function syncSurface() {
|
||||
const available = Boolean(parts.groundCaster) && state.enabled;
|
||||
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
|
||||
@@ -239,12 +226,7 @@ export function createStreetLevelLayer({
|
||||
parts.credits.hide(state.viewer, entry.def);
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest-image lookup in flight. Anything newer the user asks for (a
|
||||
* later lookup, a picked image, closing, the layer going off) aborts it,
|
||||
* so its late answer can never replace that choice.
|
||||
* @type {AbortController|null}
|
||||
*/
|
||||
/** The nearest-image lookup in flight; any newer user action aborts it. */
|
||||
let nearestLookup = null;
|
||||
|
||||
function abortNearest() {
|
||||
@@ -502,7 +484,7 @@ export function createStreetLevelLayer({
|
||||
if (!state.enabled) break;
|
||||
// This provider was switched off while it looked: try the next.
|
||||
if (!imageId || !entry.on) continue;
|
||||
nearestLookup = null; // answered: nothing left to abort
|
||||
nearestLookup = null;
|
||||
return openImage(entry.def.id, imageId);
|
||||
}
|
||||
if (nearestLookup === lookup) nearestLookup = null;
|
||||
|
||||
@@ -242,7 +242,7 @@ test('switching a provider off deactivates it and closes the image it shows', as
|
||||
assert.equal(layer.getUIState().street.providerId, null);
|
||||
});
|
||||
|
||||
test('openNearest without the panel reports it instead of loading forever (review P3)', async (t) => {
|
||||
test('openNearest without the panel reports it instead of loading forever', async (t) => {
|
||||
const provider = fakeProvider({ nearestImage: async () => 'img1' });
|
||||
const { layer } = await enabledLayer(t, [provider]);
|
||||
assert.equal(await layer.openNearest({ lat: 38.58, lon: -121.49 }), false);
|
||||
@@ -252,7 +252,7 @@ test('openNearest without the panel reports it instead of loading forever (revie
|
||||
assert.equal(provider.calls.mount, 0);
|
||||
});
|
||||
|
||||
test('openNearest opens nothing once the layer went off during the lookup (review P3)', async (t) => {
|
||||
test('openNearest opens nothing once the layer went off during the lookup', async (t) => {
|
||||
const answer = deferred();
|
||||
const provider = fakeProvider({ nearestImage: () => answer.promise });
|
||||
const { layer } = await enabledLayer(t, [provider]);
|
||||
@@ -268,7 +268,7 @@ test('openNearest opens nothing once the layer went off during the lookup (revie
|
||||
assert.equal(street.error, null);
|
||||
});
|
||||
|
||||
test('openNearest skips a provider switched off during its lookup (review P3)', async (t) => {
|
||||
test('openNearest skips a provider switched off during its lookup', async (t) => {
|
||||
const answer = deferred();
|
||||
const provider = fakeProvider({ nearestImage: () => answer.promise });
|
||||
const { layer } = await enabledLayer(t, [provider]);
|
||||
@@ -281,7 +281,7 @@ test('openNearest skips a provider switched off during its lookup (review P3)',
|
||||
assert.equal(layer.getUIState().street.loading, false);
|
||||
});
|
||||
|
||||
test('a provider withdraws only the error it reported (review P3)', async (t) => {
|
||||
test('a provider withdraws only the error it reported', async (t) => {
|
||||
const provider = fakeProvider();
|
||||
const { layer } = await enabledLayer(t, [provider]);
|
||||
const { actions } = provider.context();
|
||||
@@ -298,7 +298,7 @@ test('a provider withdraws only the error it reported (review P3)', async (t) =>
|
||||
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
|
||||
});
|
||||
|
||||
test('closing the photo stops the globe flying to it (review IC8 P1)', async (t) => {
|
||||
test('closing the photo stops the globe flying to it', async (t) => {
|
||||
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
|
||||
layer.attachViewerHost({});
|
||||
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||||
@@ -307,7 +307,7 @@ test('closing the photo stops the globe flying to it (review IC8 P1)', async (t)
|
||||
assert.equal(viewer.flights.cancelled, 1);
|
||||
});
|
||||
|
||||
test('switching the layer off or destroying it stops the framing flight (review IC8 P1)', async (t) => {
|
||||
test('switching the layer off or destroying it stops the framing flight', async (t) => {
|
||||
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
|
||||
layer.attachViewerHost({});
|
||||
await layer.openImage('mapillary', 'img1');
|
||||
@@ -320,7 +320,7 @@ test('switching the layer off or destroying it stops the framing flight (review
|
||||
assert.equal(viewer.flights.cancelled, 2, 'destroyed');
|
||||
});
|
||||
|
||||
test('closing the photo leaves a newer navigation flight alone (review IC8 P1)', async (t) => {
|
||||
test('closing the photo leaves a newer navigation flight alone', async (t) => {
|
||||
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
|
||||
layer.attachViewerHost({});
|
||||
await layer.openImage('mapillary', 'img1');
|
||||
@@ -331,7 +331,7 @@ test('closing the photo leaves a newer navigation flight alone (review IC8 P1)',
|
||||
assert.equal(viewer.flights.cancelled, 0, 'the search flight keeps going');
|
||||
});
|
||||
|
||||
test('an older nearest lookup that answers late cannot replace a newer one (review IC8 P2)', async (t) => {
|
||||
test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
|
||||
const { lookups, nearestImage } = slowLookups();
|
||||
const provider = fakeProvider({ nearestImage });
|
||||
const { layer } = await enabledLayer(t, [provider]);
|
||||
@@ -347,7 +347,7 @@ test('an older nearest lookup that answers late cannot replace a newer one (revi
|
||||
assert.equal(lookups[1].signal?.aborted, false);
|
||||
});
|
||||
|
||||
test('an image picked during a nearest lookup wins over its late answer (review IC8 P2)', async (t) => {
|
||||
test('an image picked during a nearest lookup wins over its late answer', async (t) => {
|
||||
const { lookups, nearestImage } = slowLookups();
|
||||
const provider = fakeProvider({ nearestImage });
|
||||
const { layer } = await enabledLayer(t, [provider]);
|
||||
@@ -361,7 +361,7 @@ test('an image picked during a nearest lookup wins over its late answer (review
|
||||
assert.equal(lookups[0].signal?.aborted, true);
|
||||
});
|
||||
|
||||
test('closing the viewer or switching the layer off retires a nearest lookup (review IC8 P2)', async (t) => {
|
||||
test('closing the viewer or switching the layer off retires a nearest lookup', async (t) => {
|
||||
const { lookups, nearestImage } = slowLookups();
|
||||
const provider = fakeProvider({ nearestImage });
|
||||
const { layer, viewer } = await enabledLayer(t, [provider]);
|
||||
@@ -393,7 +393,7 @@ test('closing the viewer or switching the layer off retires a nearest lookup (re
|
||||
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
|
||||
});
|
||||
|
||||
test('the Mapillary provider hands the lookup signal to its source (review IC8 P2)', async () => {
|
||||
test('the Mapillary provider hands the lookup signal to its source', async () => {
|
||||
const requests = [];
|
||||
const source = {
|
||||
hasToken: () => true,
|
||||
|
||||
@@ -6,11 +6,7 @@ import { createHorizonCull } from './view.js';
|
||||
|
||||
const SPRITE_ID = 'street-level:marker';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
|
||||
export function createMarker({ state, parts }) {
|
||||
const { render, sprites } = state.services;
|
||||
let last = null;
|
||||
@@ -36,9 +32,8 @@ export function createMarker({ state, parts }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Height for the position in terrain mode: bare earth, refined by the
|
||||
* sampled Google 3D surface where it is known; null to clamp. Fetches a
|
||||
* missing terrain cell and asks for a mesh sample under the marker.
|
||||
* Terrain-mode height (bare earth refined by mesh samples), or null to
|
||||
* clamp. Requests a missing terrain cell or mesh sample as a side effect.
|
||||
*/
|
||||
function castHeight(position) {
|
||||
const ground = parts?.groundCaster;
|
||||
|
||||
@@ -52,7 +52,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
|
||||
return { state, view, preRender, marker, frame };
|
||||
}
|
||||
|
||||
test('the photo marker is hidden behind the globe, and shows from its side (review IC8 P2)', () => {
|
||||
test('the photo marker is hidden behind the globe, and shows from its side', () => {
|
||||
const { state, view, preRender, marker, frame } = setup();
|
||||
// Placed on the far side of the Earth from the camera.
|
||||
marker.set({ lon: 58.51, lat: -38.58 }, 90);
|
||||
|
||||
@@ -2,20 +2,11 @@ import * as Cesium from 'cesium';
|
||||
import { metresBetween, whenIdle } from './view.js';
|
||||
|
||||
/**
|
||||
* Rendered-surface heights for ground casting on Google 3D (see groundCast.js
|
||||
* refineHeights). One `scene.sampleHeight` probe per ~11 m cell, cached, so
|
||||
* the many sequences that share a street share their samples.
|
||||
*
|
||||
* `sampleHeight` renders a pick pass per probe (about 1–3 ms), so probes run
|
||||
* in idle time within a small budget, nearest the camera first, and only
|
||||
* within MESH_SAMPLE_RADIUS_M of the point under the camera: the streamed
|
||||
* detail is fine there, and an error is visible at all. Requests are taken
|
||||
* in by the same budgeted slices, cells out of range then are not queued
|
||||
* (callers ask again when the camera moves), and the queue is a heap ranked
|
||||
* from where the camera was, re-ranked only once it has moved MESH_RERANK_M,
|
||||
* so no slice walks or sorts the whole queue. A probe that hits
|
||||
* nothing (tiles not streamed yet) is retried later rather than latched.
|
||||
* Overlays are excluded from the probe, so only 3D tilesets are hit.
|
||||
* Google 3D surface heights for ground casting (groundCast.js refineHeights):
|
||||
* one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a
|
||||
* pick pass (1–3 ms), so probes run in budgeted idle slices, nearest the
|
||||
* camera first and only within MESH_SAMPLE_RADIUS_M. A miss (tiles not
|
||||
* streamed yet) is retried later. Probes hit 3D tilesets only.
|
||||
*/
|
||||
|
||||
/** Cell size, in degrees (~11 m): samples are shared within a cell. */
|
||||
@@ -35,10 +26,7 @@ const MESH_NOTIFY_MS = 700;
|
||||
|
||||
const cellOf = (value) => Math.round(value / MESH_CELL_DEG);
|
||||
|
||||
/**
|
||||
* The cache key of the mesh cell holding a point, one per ~11 m cell: a
|
||||
* number (exact well within 2^53), as a string key would be built per point.
|
||||
*/
|
||||
/** Numeric key of the cell holding a point (exact within 2^53; no per-point string). */
|
||||
export const meshCellKey = (lon, lat) =>
|
||||
(cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000;
|
||||
|
||||
@@ -70,7 +58,6 @@ function siftDown(heap, i) {
|
||||
heap[i] = cell;
|
||||
}
|
||||
|
||||
/** Take the nearest cell off a min-heap. */
|
||||
function heapPop(heap) {
|
||||
const top = heap[0];
|
||||
const last = heap.pop();
|
||||
@@ -104,11 +91,7 @@ export function createMeshSampler({
|
||||
}) {
|
||||
/** cell key → sampled mesh height (ellipsoidal metres). */
|
||||
const heights = new Map();
|
||||
/**
|
||||
* cell key → retry time for probes that hit nothing. An entry goes once
|
||||
* its cell is sampled or asked for after the retry time, and with the
|
||||
* height cache, so the map stays bounded.
|
||||
*/
|
||||
/** cell key → retry time for probes that hit nothing; bounded like the height cache. */
|
||||
const misses = new Map();
|
||||
/** cell key → queued cell, so a cell is queued once. */
|
||||
const wanted = new Map();
|
||||
@@ -142,10 +125,8 @@ export function createMeshSampler({
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for the cells under these [lon, lat] points (read later, so do not
|
||||
* change the list). Nothing is done here: idle slices take requests in
|
||||
* within their budget, and drop cells out of range of where the camera was
|
||||
* when asked; callers ask again once it moves.
|
||||
* Ask for the cells under [lon, lat] points; the list is read later, so do
|
||||
* not mutate it. Out-of-range cells are dropped; callers ask again on move.
|
||||
*/
|
||||
function request(points) {
|
||||
if (!enabled || !points?.length) return;
|
||||
@@ -288,7 +269,6 @@ export function createMeshSampler({
|
||||
schedule();
|
||||
}
|
||||
|
||||
/** Drop every sample and remembered miss. */
|
||||
function forget() {
|
||||
heights.clear();
|
||||
misses.clear();
|
||||
|
||||
@@ -109,7 +109,7 @@ test('a missed cell is probed again after the retry window, not before', (t) =>
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('remembered misses are bounded like the samples (review P3)', (t) => {
|
||||
test('remembered misses are bounded like the samples', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const { viewer, probes } = fakeViewer({ holes: () => true });
|
||||
const sampler = createMeshSampler({ getViewer: () => viewer, cacheMax: 2 });
|
||||
@@ -191,7 +191,7 @@ function grid(lon, lat, n, step) {
|
||||
return points;
|
||||
}
|
||||
|
||||
test('cells out of range when asked are not queued, even once the camera reaches them (review IC8 P2)', (t) => {
|
||||
test('cells out of range when asked are not queued, even once the camera reaches them', (t) => {
|
||||
const { sampler, probes, slice, moveTo } = clocked(t);
|
||||
// 400 cells ~3.6 km east, past the sampler's range.
|
||||
sampler.request(grid(10.05, 50, 20, 0.0002));
|
||||
@@ -206,7 +206,7 @@ test('cells out of range when asked are not queued, even once the camera reaches
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('a slice keeps to its budget, counting the work before its first probe (review IC8 P2)', (t) => {
|
||||
test('a slice keeps to its budget, counting the work before its first probe', (t) => {
|
||||
const { sampler, probes, slice } = clocked(t, { probeMs: 2, cameraMs: 3 });
|
||||
sampler.request(grid(10.001, 50, 5, 0.0002));
|
||||
const lengths = [];
|
||||
@@ -217,7 +217,7 @@ test('a slice keeps to its budget, counting the work before its first probe (rev
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('a large queue is ranked once, not sorted again every slice (review IC8 P2)', (t) => {
|
||||
test('a large queue is ranked once, not sorted again every slice', (t) => {
|
||||
const { sampler, probes, slice, moveTo } = clocked(t);
|
||||
// 2,500 cells ~17 m apart around the camera, all within range.
|
||||
const cells = grid(10, 50, 50, 0.00015);
|
||||
@@ -255,7 +255,7 @@ test('a large queue is ranked once, not sorted again every slice (review IC8 P2)
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('the sampling range is measured the short way round the date line (review IC8 P2)', (t) => {
|
||||
test('the sampling range is measured the short way round the date line', (t) => {
|
||||
const { sampler, probes, slice, moveTo } = clocked(t);
|
||||
moveTo(179.9999, 0);
|
||||
sampler.request([
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { normalizeFilter } from './filter.js';
|
||||
|
||||
/**
|
||||
* Share-link and stored parameters: one boolean per registered provider plus
|
||||
* the shared imagery filter. The codec in src/data/layerState.js names the
|
||||
* same keys, so a provider added later also needs an option there.
|
||||
* Share-link params: one boolean per provider plus the filter. The codec in
|
||||
* src/data/layerState.js names the same keys, so a new provider needs one there.
|
||||
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
|
||||
*/
|
||||
export function encodeParams({ providers, filter }) {
|
||||
@@ -15,8 +14,8 @@ export function encodeParams({ providers, filter }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read parameters back. Unknown providers and malformed values are ignored,
|
||||
* so a link written for a build with more providers still applies cleanly.
|
||||
* Read params back, ignoring unknown providers and malformed values so a link
|
||||
* from a build with more providers still applies.
|
||||
* @param {object} params
|
||||
* @param {{providerIds: Iterable<string>, filter: {pano: string, sinceDays: number}}} current
|
||||
* @returns {{providers: Map<string, boolean>, filter: {pano: string, sinceDays: number}}}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* Route picked primitive ids to the provider whose prefix they carry. The
|
||||
* registry guarantees prefixes never overlap, so the first match is the only
|
||||
* match.
|
||||
* Route picked ids to the provider whose prefix they carry; the registry
|
||||
* guarantees prefixes never overlap.
|
||||
* @param {() => Iterable<{def: {id: string, pickPrefix: string}, instance: object}>} getProviders
|
||||
* @param {{positionId?: string}} [options] Id of the core-owned position marker.
|
||||
*/
|
||||
|
||||
@@ -4,12 +4,7 @@ export const STREET_LEVEL_LAYER_ID = 'street-level';
|
||||
/** Pick id of the viewer position marker; the core owns it, not a provider. */
|
||||
export const POSITION_PICK_ID = 'sl:pos';
|
||||
|
||||
/**
|
||||
* One colour per imagery source, used everywhere that source appears: its
|
||||
* chip, coverage lines, overview points, image cones and legend swatch.
|
||||
* Registered providers take their entry from here; a new source adds its
|
||||
* colour together with its provider.
|
||||
*/
|
||||
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
|
||||
export const PROVIDER_COLORS = Object.freeze({
|
||||
mapillary: '#05cb63',
|
||||
});
|
||||
@@ -29,16 +24,12 @@ export const MAX_SINCE_DAYS = 36_500;
|
||||
/** Filter every provider starts with: all imagery, any date. */
|
||||
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
|
||||
|
||||
/**
|
||||
* Nearest-image search when the user asks to look at a place, in metres
|
||||
* (Mapillary's graph API answers within at most 50 m).
|
||||
*/
|
||||
/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
|
||||
export const NEAREST_RADIUS_M = 50;
|
||||
|
||||
/**
|
||||
* Map stack the globe camera may follow the street-level view on. Only Google
|
||||
* Photorealistic 3D has buildings to stand among; on flat imagery or terrain
|
||||
* a camera at eye height looks at a smeared texture.
|
||||
* The only map stack camera follow is offered on: elsewhere a camera at eye
|
||||
* height looks at a smeared texture.
|
||||
*/
|
||||
export const FOLLOW_MAP_STACK_ID = 'photoreal';
|
||||
|
||||
|
||||
@@ -41,11 +41,7 @@ const REMESH_INTERVAL_MS = 1500;
|
||||
const REMESH_STAGGER_MS = 120;
|
||||
/** Old-zoom tiles are kept at most this long after a zoom change. */
|
||||
const STALE_TILE_MAX_MS = 6000;
|
||||
/**
|
||||
* Sequences per ground primitive. Ground polyline geometry is built on a
|
||||
* worker in proportion to the instance count, so smaller batches put the
|
||||
* first lines on screen sooner instead of one big batch arriving late.
|
||||
*/
|
||||
/** Sequences per primitive: smaller batches build, and show, sooner. */
|
||||
const SEQUENCE_PRIMITIVE_BATCH = 120;
|
||||
|
||||
const PER_TILE_SEQUENCE_CAP = Math.floor(
|
||||
@@ -71,12 +67,7 @@ function partIds(sequence) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequence id for a picked line, whichever part was hit: `mly:seq:<id>` and
|
||||
* `mly:seq:<id>~<part>` both give `<id>`; null for any other pick.
|
||||
* @param {string} pickId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
/** Sequence id for any part's pick id (`mly:seq:<id>[~<part>]`), else null. */
|
||||
export function sequenceIdFromPick(pickId) {
|
||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence))
|
||||
return null;
|
||||
@@ -86,22 +77,11 @@ export function sequenceIdFromPick(pickId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* Whether any point of a line's bounding `box` is within the mesh sampler's
|
||||
* range of the camera ground point `centre`, measured the short way round
|
||||
* the date line.
|
||||
* Whether any point of `box` ({west, south, east, north}) is within the mesh
|
||||
* sampler's range of `centre`, the ground point under the camera.
|
||||
*/
|
||||
export function meshBoxInRange(box, centre) {
|
||||
// Take the camera's longitude to the box's side of the date line first:
|
||||
// Move the camera's longitude to the box's side of the date line first:
|
||||
// clamping 179.999 into a box at -179.99 would pick its far edge.
|
||||
const lon =
|
||||
centre.lon +
|
||||
@@ -113,6 +93,12 @@ export function meshBoxInRange(box, centre) {
|
||||
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera-driven coverage: overview points (z0–5) from orbit, sequence lines
|
||||
* (z11–14) near the ground. In terrain surface mode a tile is drawn draped
|
||||
* first, then swapped for lines cast to the bare earth (draped lines would
|
||||
* land on roofs), so coverage never waits on terrain heights.
|
||||
*/
|
||||
export function createCoverage({ state, source }) {
|
||||
const { render } = state.services;
|
||||
|
||||
@@ -142,9 +128,8 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The sequences a tile draws: newest first, past the imagery filter, then
|
||||
* capped. Capping before the filter could leave a dense tile of newer flat
|
||||
* captures with no 360° lines at all.
|
||||
* A tile's sequences, filtered then capped: capping first could leave a
|
||||
* dense tile of newer flat captures with no 360° lines at all.
|
||||
*/
|
||||
function drawnSequences(entry) {
|
||||
const drawn = [];
|
||||
@@ -157,11 +142,9 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Primitives for a tile's sequences, in draw batches: ground primitives
|
||||
* draped on the globe, plus (in terrain mode) plain polylines at the cast
|
||||
* heights for every part whose heights are cached. Each part of a sequence
|
||||
* with a capture gap is its own line. Each batch remembers the selection
|
||||
* its colours were built with.
|
||||
* Batched primitives for a tile's sequence parts: cast polylines where
|
||||
* terrain heights are cached, draped ground lines otherwise. Each batch
|
||||
* records the selection its colours were built with.
|
||||
*/
|
||||
function buildSequencePrimitives(sequences) {
|
||||
const ground = terrainMode() ? state.context.groundCaster : null;
|
||||
@@ -233,13 +216,11 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the terrain heights a tile's lines need, then redraw the tile cast.
|
||||
* `castRequested` holds while the tile's castable lines are cast; a cast
|
||||
* that resolves nothing (terrain proxy down, tile too big) clears it, so
|
||||
* the next refresh tries again rather than this retrying in a loop.
|
||||
* Fetch the terrain heights a tile's lines need, then redraw it cast. A
|
||||
* cast that resolves nothing clears `castRequested`, so the next refresh
|
||||
* retries instead of this looping.
|
||||
*/
|
||||
function castTile(entry) {
|
||||
// `castAbort` is set while a cast is in flight.
|
||||
if (entry.castRequested || entry.castAbort || !terrainMode()) return;
|
||||
entry.castRequested = true;
|
||||
entry.castAbort = new AbortController();
|
||||
@@ -264,10 +245,7 @@ export function createCoverage({ state, source }) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a tile's lines (cast, or with new mesh heights), keeping the old
|
||||
* ones on screen until the new ones are built, so nothing blinks.
|
||||
*/
|
||||
/** Rebuild a tile's lines; the old ones stay up until the new are ready. */
|
||||
function redrawTile(entry) {
|
||||
const previous = entry.primitives;
|
||||
entry.primitives = [];
|
||||
@@ -275,7 +253,7 @@ export function createCoverage({ state, source }) {
|
||||
removeWhenReady(entry, previous);
|
||||
}
|
||||
|
||||
/** The point under the camera: where the mesh sampler measures its range from. */
|
||||
/** Lon/lat under the camera, where mesh range is measured from. */
|
||||
function meshCentre() {
|
||||
const carto = state.viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
@@ -285,11 +263,9 @@ export function createCoverage({ state, source }) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether any of a {west, south, east, north} box is within the sampler's range. */
|
||||
|
||||
/**
|
||||
* A tile's drawn lines with their bounding boxes, built once per filter;
|
||||
* each line's mesh points are densified the first time it is in range.
|
||||
* A tile's drawn lines with bounding boxes, built once per filter; mesh
|
||||
* points are densified the first time a line is in range.
|
||||
*/
|
||||
function meshParts(entry) {
|
||||
if (entry.meshParts) return entry.meshParts;
|
||||
@@ -332,10 +308,7 @@ export function createCoverage({ state, source }) {
|
||||
if (points.length) sampler.request(points);
|
||||
}
|
||||
|
||||
/**
|
||||
* New mesh samples landed: redraw the tiles they fall in, throttled, with
|
||||
* the same no-blink swap as the bare-earth cast.
|
||||
*/
|
||||
/** Redraw the tiles new mesh samples fall in, throttled. */
|
||||
function onMeshSampled(batch) {
|
||||
if (!terrainMode() || !state.context.isActive()) return;
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
@@ -543,9 +516,8 @@ export function createCoverage({ state, source }) {
|
||||
});
|
||||
if (kind === 'sequence') await ensureTerrainReady();
|
||||
const bytes = await fetching;
|
||||
// A refresh that still wants this tile must not throw the bytes away;
|
||||
// only an abort (tile no longer wanted, or zoom changed) does.
|
||||
// A retire, clear or newer request for this tile owns it now.
|
||||
// Only an abort, a retire, a clear or a newer request for this tile
|
||||
// discards the bytes; a refresh that still wants the tile keeps them.
|
||||
const current = () =>
|
||||
state.coverage.pending.get(key) === controller &&
|
||||
!state.coverage.tiles.has(key);
|
||||
@@ -644,10 +616,7 @@ export function createCoverage({ state, source }) {
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Move every loaded tile to the stale set instead of removing it, so the
|
||||
* old zoom stays visible while the new zoom streams in (no blank globe).
|
||||
*/
|
||||
/** Move tiles to the stale set: the old zoom shows until the new one loads. */
|
||||
function retire() {
|
||||
for (const controller of state.coverage.pending.values())
|
||||
controller.abort();
|
||||
@@ -812,9 +781,8 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraw loaded tiles draped or cast after the core's surface mode changes,
|
||||
* then re-pick the zoom: the bare-earth height under the camera that the
|
||||
* change brings in can move it (a high city reads much closer to the street).
|
||||
* Redraw tiles draped or cast for the new surface mode, then re-pick the
|
||||
* zoom, since the ground height under the camera may have changed.
|
||||
*/
|
||||
function setSurface() {
|
||||
if (!state.context.isActive()) return;
|
||||
@@ -839,10 +807,7 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tile drawn on the globe: the current zoom's, and the old zoom's
|
||||
* kept visible until the new one is in (see retire).
|
||||
*/
|
||||
/** Every tile on the globe: the current zoom's and the stale zoom's. */
|
||||
function drawnEntries() {
|
||||
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
|
||||
}
|
||||
@@ -886,10 +851,8 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Colours are baked in when a tile's lines are built, and a primitive
|
||||
* cannot be recoloured until it is ready. Cast swaps and remeshes rebuild
|
||||
* tiles all the time, so while any primitive is building, catch each one
|
||||
* up with the selection made or cleared meanwhile as it becomes ready.
|
||||
* A primitive cannot be recoloured until it is ready, so while any is
|
||||
* building, catch each one up with the selection as it becomes ready.
|
||||
*/
|
||||
function watchSelection() {
|
||||
const scene = state.viewer?.scene;
|
||||
|
||||
@@ -171,7 +171,7 @@ function frame(listeners) {
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
|
||||
test('a superseded tile request cannot strand lines on the globe (review P0 #2)', async () => {
|
||||
test('a superseded tile request cannot strand lines on the globe', async () => {
|
||||
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
|
||||
const forTile = () => source.calls.filter((call) => call.key === tileKey);
|
||||
|
||||
@@ -304,7 +304,7 @@ test('zoom 0 is a zoom: the whole-earth view still shows coverage', () => {
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a rejected key stops coverage requests until the layer goes off (review IC8 P2)', async () => {
|
||||
test('a rejected key stops coverage requests until the layer goes off', async () => {
|
||||
const { viewer, source, state, coverage } = setup();
|
||||
coverage.refresh();
|
||||
const rejected = Object.assign(
|
||||
@@ -330,7 +330,7 @@ test('a rejected key stops coverage requests until the layer goes off (review IC
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a rate limit keeps the drawn tiles and asks again once the wait is over (review IC8 P2)', async (t) => {
|
||||
test('a rate limit keeps the drawn tiles and asks again once the wait is over', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const { viewer, source, state, coverage } = setup();
|
||||
coverage.refresh();
|
||||
@@ -566,7 +566,7 @@ test('a selection made or cleared while a tile builds is applied once it is read
|
||||
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
|
||||
});
|
||||
|
||||
test('a sequence that crosses a tile edge is counted once (review P3)', async () => {
|
||||
test('a sequence that crosses a tile edge is counted once', async () => {
|
||||
const { viewer, source, coverage, centre } = setup();
|
||||
// Look straight down on the edge between this tile and its east neighbour.
|
||||
const { east } = tileBounds(centre.tile.x, centre.tile.y, 14);
|
||||
@@ -598,10 +598,7 @@ test('a sequence that crosses a tile edge is counted once (review P3)', async ()
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
/**
|
||||
* In terrain mode with a cast tile mid-swap: the draped lines and the cast
|
||||
* primitive whose readiness the test controls.
|
||||
*/
|
||||
/** A terrain-mode tile mid-swap, with a cast primitive the test readies. */
|
||||
async function midSwap() {
|
||||
// Drawn draped first; castLine only answers once the heights are in.
|
||||
let heightsReady = false;
|
||||
@@ -665,9 +662,8 @@ function tiltedCamera({ lon, lat, height, heading, pitch }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Denver, 300 m above a street 1,600 m above the ellipsoid, at the centre of
|
||||
* a z14 tile and looking east with the screen centre `aheadM` metres out.
|
||||
* Google 3D hides the globe, so only the bare-earth caster knows the ground.
|
||||
* Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east
|
||||
* at ground `aheadM` metres out. Only the bare-earth caster knows the ground.
|
||||
*/
|
||||
function denverStreetView(aheadM) {
|
||||
const z = 14;
|
||||
@@ -735,7 +731,7 @@ test('a street view toward the horizon ranks only the ground within range', () =
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a selection cleared while the old zoom is still shown uncolours its lines (review IC8 P2)', async () => {
|
||||
test('a selection cleared while the old zoom is still shown uncolours its lines', async () => {
|
||||
const { viewer, source, state, coverage, bytes } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
@@ -760,7 +756,7 @@ test('a selection cleared while the old zoom is still shown uncolours its lines
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a selection made while the old zoom is still building is applied once it is ready (review IC8 P2)', async () => {
|
||||
test('a selection made while the old zoom is still building is applied once it is ready', async () => {
|
||||
const { viewer, source, state, coverage, bytes } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
@@ -784,7 +780,7 @@ test('a selection made while the old zoom is still building is applied once it i
|
||||
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
|
||||
});
|
||||
|
||||
test('only lines within the mesh sampler range of the camera are asked for (review IC8 P2)', async () => {
|
||||
test('only lines within the mesh sampler range of the camera are asked for', async () => {
|
||||
let heightsReady = false;
|
||||
const groundCaster = {
|
||||
prepareLines: async () => (heightsReady = true),
|
||||
@@ -874,7 +870,7 @@ test('the old zoom goes as soon as the new zoom has loaded, not after the stale
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a rate limit holds every refresh inside its wait, not only the first (review IC8 P2)', async (t) => {
|
||||
test('a rate limit holds every refresh inside its wait, not only the first', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const { viewer, source, coverage } = setup();
|
||||
coverage.refresh();
|
||||
@@ -898,7 +894,7 @@ test('a rate limit holds every refresh inside its wait, not only the first (revi
|
||||
coverage.resetErrors();
|
||||
});
|
||||
|
||||
test('a line across the date line from the camera is in mesh range (gekh P3)', () => {
|
||||
test('a line across the date line from the camera is in mesh range', () => {
|
||||
// A z14 tile's width of lines just past -180, the camera 111 m short of it.
|
||||
const camera = { lon: 179.999, lat: 0 };
|
||||
const east = { west: -180, east: -179.978, south: -0.001, north: 0.001 };
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { PbfWriter } from 'pbf';
|
||||
|
||||
/**
|
||||
* Encode a Mapillary-shaped coverage tile (mly1_public: `sequence` lines and
|
||||
* `overview` points) from lon/lat features, for tests and the hermetic QA
|
||||
* gate. The decoder in ./decode.js reads it like a real tile.
|
||||
*/
|
||||
|
||||
const zigzag = (value) => (value << 1) ^ (value >> 31);
|
||||
|
||||
/** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */
|
||||
@@ -67,7 +61,7 @@ function layerWriter(name, extent) {
|
||||
};
|
||||
}
|
||||
|
||||
/** MoveTo/LineTo commands for line parts, with the cursor carried across parts. */
|
||||
/** MVT MoveTo/LineTo commands; the cursor carries over between parts. */
|
||||
function lineGeometry(parts) {
|
||||
const out = [];
|
||||
let cx = 0;
|
||||
@@ -89,9 +83,10 @@ function lineGeometry(parts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a `mly1_public`-shaped tile from lon/lat features, for tests and
|
||||
* the hermetic QA gate.
|
||||
* @param {{x: number, y: number, z: number}} tile
|
||||
* @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array<Array<[number, number]>>}>, overview?: Array<{id: string, lon: number, lat: number, capturedAt?: number, isPano?: boolean}>, extent?: number}} [content]
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function encodeCoverageTile(
|
||||
tile,
|
||||
|
||||
@@ -3,13 +3,10 @@ import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { tileLocalToLonLat } from '../../tileMath.js';
|
||||
|
||||
/**
|
||||
* Decode a Mapillary coverage tile (`mly1_public`) in the browser.
|
||||
* Sequences are LineStrings with capture metadata; z0–5 tiles carry
|
||||
* `overview` points instead. The z14 `image` layer (it can hold >150k
|
||||
* points) is never read.
|
||||
* Decode a `mly1_public` coverage tile into sequences and (z0–5) overview
|
||||
* points. The z14 `image` layer, which can hold >150k points, is skipped.
|
||||
* @param {Uint8Array} bytes
|
||||
* @param {{x:number,y:number,z:number}} address
|
||||
* @returns {{sequences: Array<object>, overview: Array<object>}}
|
||||
*/
|
||||
export function decodeCoverageTile(bytes, address) {
|
||||
const result = { sequences: [], overview: [] };
|
||||
@@ -44,7 +41,6 @@ export function decodeCoverageTile(bytes, address) {
|
||||
});
|
||||
}
|
||||
}
|
||||
// z0–5 tiles carry an `overview` point layer instead of sequences.
|
||||
const overviewLayer = tile.layers.overview;
|
||||
if (overviewLayer) {
|
||||
for (let i = 0; i < overviewLayer.length; i++) {
|
||||
|
||||
@@ -23,19 +23,16 @@ function createProviderState(context) {
|
||||
services: context.services,
|
||||
viewer: null,
|
||||
keyRequired: false,
|
||||
/**
|
||||
* False until the first status answer: coverage asks for nothing before
|
||||
* it, so a key-less install never sends tile requests that get 503.
|
||||
*/
|
||||
/** Coverage waits for the first status: no tile requests without a key. */
|
||||
statusKnown: false,
|
||||
/** Mapillary refused the configured token: nothing is requested until re-enabled. */
|
||||
/** Mapillary refused the token: request nothing until re-enabled. */
|
||||
keyRejected: false,
|
||||
status: null,
|
||||
coverage: {
|
||||
zoom: null,
|
||||
/** @type {Map<string, {primitive: object|null, sequences: Map<string, object>, count: number}>} */
|
||||
/** Current zoom's tiles by `z/x/y` key. */
|
||||
tiles: new Map(),
|
||||
/** Tiles from the previous zoom, kept on screen until replacements land. */
|
||||
/** Previous zoom's tiles, kept on screen until replacements land. */
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
/** Tile key → its request's controller; any entry means LOADING. */
|
||||
@@ -53,7 +50,7 @@ function createProviderState(context) {
|
||||
sequence: {
|
||||
selectedId: null,
|
||||
images: [],
|
||||
/** @type {Map<string, Array<object>>} recent sequences' thinned images */
|
||||
/** Recent sequences' thinned images, by sequence id. */
|
||||
cache: new Map(),
|
||||
collection: null,
|
||||
loading: false,
|
||||
@@ -63,9 +60,8 @@ function createProviderState(context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapillary as a Street Level provider: coverage from cached vector tiles,
|
||||
* per-sequence image cones from the Graph API and the MapillaryJS viewer.
|
||||
* @param {{source: object}} options A source from ./source.js (or a stand-in with the same methods).
|
||||
* Mapillary Street Level provider: tile coverage, Graph API image cones and
|
||||
* the MapillaryJS viewer. `source` is ./source.js or a stand-in.
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createMapillaryProvider({ source }) {
|
||||
|
||||
@@ -24,10 +24,7 @@ export const PICK_PREFIX = Object.freeze({
|
||||
image: 'mly:img:',
|
||||
});
|
||||
|
||||
/**
|
||||
* Mapillary draws in one colour, its brand green, for lines, overview points
|
||||
* and cones alike (360° cones keep their ring shape); selection is GEV cyan.
|
||||
*/
|
||||
/** Mapillary green for lines, points and cones; GEV cyan for selection. */
|
||||
export const COLORS = Object.freeze({
|
||||
coverage: PROVIDER_COLORS.mapillary,
|
||||
selected: SHARED_COLORS.selected,
|
||||
@@ -45,9 +42,8 @@ export const KEY_REJECTED_MESSAGE =
|
||||
export const RATE_LIMITED_MESSAGE =
|
||||
'Coverage requests are being rate-limited — they resume on their own';
|
||||
/**
|
||||
* How far from the camera a street-zoom view may reach for coverage: a tilted
|
||||
* view sees to the horizon, but only the ground within this range is ranked
|
||||
* and boxed. Scales with camera height, never below the floor.
|
||||
* Street-zoom coverage reaches this far from the camera: camera height times
|
||||
* the factor, never below the floor, even when a tilted view sees further.
|
||||
*/
|
||||
export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500;
|
||||
export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10;
|
||||
@@ -65,10 +61,7 @@ export const IMAGE_CONE_MIN_SPACING_M = 3;
|
||||
/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */
|
||||
export const NEAREST_LIMIT = 8;
|
||||
|
||||
/**
|
||||
* On-globe credit shown while the provider is active. Mapillary imagery and
|
||||
* derived data are CC BY-SA 4.0 and require visible attribution.
|
||||
*/
|
||||
/** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */
|
||||
export const MAPILLARY_CREDIT_HTML =
|
||||
'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, CC BY-SA 4.0';
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ test('nearestImage picks the closest image, not the first the API returned', asy
|
||||
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'near');
|
||||
});
|
||||
|
||||
test('a "since N days" window follows the clock in a long-open tab (review P3)', async (t) => {
|
||||
test('a "since N days" window follows the clock in a long-open tab', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: Date.UTC(2026, 9, 1) });
|
||||
const day = 86_400_000;
|
||||
const capturedAt = Date.now() - day / 2;
|
||||
@@ -140,7 +140,7 @@ test('a "since N days" window follows the clock in a long-open tab (review P3)',
|
||||
);
|
||||
});
|
||||
|
||||
test('nearestImage measures the short way round the date line (review IC8 P2)', async () => {
|
||||
test('nearestImage measures the short way round the date line', async () => {
|
||||
const at = (id, lon, lat) => ({
|
||||
id,
|
||||
is_pano: false,
|
||||
@@ -167,7 +167,7 @@ test('nearestImage measures the short way round the date line (review IC8 P2)',
|
||||
assert.equal(await west.nearestImage({ lat: 0, lon: -179.9999 }), 'across');
|
||||
});
|
||||
|
||||
test('cone thinning across the date line drops images metres apart (review IC8 P2)', () => {
|
||||
test('cone thinning across the date line drops images metres apart', () => {
|
||||
const image = (id, lon) => ({ id, lon, lat: 0 });
|
||||
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
|
||||
const kept = thinImages(
|
||||
@@ -220,7 +220,7 @@ function streetViewer() {
|
||||
};
|
||||
}
|
||||
|
||||
test('coverage asks for no tiles before the key status is known (gekh P3)', async () => {
|
||||
test('coverage asks for no tiles before the key status is known', async () => {
|
||||
for (const configured of [false, true]) {
|
||||
let answer;
|
||||
const tiles = [];
|
||||
|
||||
@@ -58,12 +58,8 @@ export function createSequences({ state, source, parts }) {
|
||||
state.context.notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* The cones on the globe: each drawn image's index, billboard and height
|
||||
* (null while clamped), and the mesh cells under them, so a mesh sample
|
||||
* moves only the cones it can affect.
|
||||
*/
|
||||
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
|
||||
/** Drawn cones, plus the mesh cells under them, so a sample moves only those. */
|
||||
let drawn = nothingDrawn();
|
||||
|
||||
// The cones skip the depth test: hide the ones behind the globe.
|
||||
@@ -105,11 +101,8 @@ export function createSequences({ state, source, parts }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: bare earth,
|
||||
* refined along the sequence by the sampled Google 3D surface. Null when
|
||||
* draped, or when a terrain height is not cached yet; that fetches the
|
||||
* heights and draws the cones again.
|
||||
* Terrain-mode cone heights on the street, not on roofs: bare earth refined
|
||||
* by mesh samples. Null when draped or while terrain heights are fetched.
|
||||
*/
|
||||
function coneHeights(images, { request = true } = {}) {
|
||||
const ground = state.context.groundCaster;
|
||||
@@ -173,10 +166,8 @@ export function createSequences({ state, source, parts }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* New mesh samples landed: when any is under a cone, re-place the cones
|
||||
* whose cast height changed, in place. A sample can move a neighbour too
|
||||
* (refineHeights bridges across covered points), so every height is
|
||||
* recomputed (cheap arithmetic) but only moved cones are touched.
|
||||
* Re-place cones whose height new mesh samples changed. A sample can move
|
||||
* a neighbour too, so every height is recomputed but only moved cones touched.
|
||||
*/
|
||||
function onMeshSampled(batch) {
|
||||
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
|
||||
|
||||
@@ -91,7 +91,7 @@ function setup() {
|
||||
return { state, source, sequences, colours, errors, drawn };
|
||||
}
|
||||
|
||||
test('a failed sequence load leaves no stale cones and can be retried (review P2-4)', async () => {
|
||||
test('a failed sequence load leaves no stale cones and can be retried', async () => {
|
||||
const { state, source, sequences, colours, errors, drawn } = setup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A'));
|
||||
@@ -174,9 +174,8 @@ test('clearSelection cancels the lookup and uncolours the sequence', async () =>
|
||||
});
|
||||
|
||||
/**
|
||||
* Terrain mode (Google 3D at street zoom): bare earth 10 m everywhere, a
|
||||
* mesh sampler whose samples the test sets and announces, and cones that
|
||||
* log every change made to them or their collection.
|
||||
* Terrain mode with bare earth at 10 m, a mesh sampler the test drives, and
|
||||
* cones that log every change made to them.
|
||||
*/
|
||||
function terrainSetup() {
|
||||
const context = setup();
|
||||
@@ -240,7 +239,7 @@ function terrainSetup() {
|
||||
return { ...context, sequences, changes, items, sample, heightOf };
|
||||
}
|
||||
|
||||
test('a mesh sample away from the selected sequence changes no cone (review IC8 P1)', async () => {
|
||||
test('a mesh sample away from the selected sequence changes no cone', async () => {
|
||||
const { source, sequences, changes, items, sample } = terrainSetup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A', 3));
|
||||
@@ -251,7 +250,7 @@ test('a mesh sample away from the selected sequence changes no cone (review IC8
|
||||
assert.deepEqual(changes, [], 'nothing rebuilt or moved');
|
||||
});
|
||||
|
||||
test('a mesh sample under a cone moves that cone in place, and no other (review IC8 P1)', async () => {
|
||||
test('a mesh sample under a cone moves that cone in place, and no other', async () => {
|
||||
const { source, sequences, changes, items, sample, heightOf } =
|
||||
terrainSetup();
|
||||
sequences.select('A');
|
||||
@@ -282,7 +281,7 @@ test('a mesh sample under a cone moves that cone in place, and no other (review
|
||||
assert.deepEqual([after[0], after[2]], [before[0], before[2]]);
|
||||
});
|
||||
|
||||
test('cones behind the globe are hidden, and show again from the other side (review IC8 P2)', async () => {
|
||||
test('cones behind the globe are hidden, and show again from the other side', async () => {
|
||||
const { state, source, sequences } = setup();
|
||||
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||
const preRender = new Set();
|
||||
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
SEQUENCE_IMAGES_LIMIT,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* Error carrying the HTTP status and any server-provided payload: no token
|
||||
* (`keyRequired`), a token Mapillary rejected (`keyRejected`), or a rate
|
||||
* limit with the seconds to wait (`retryAfterSec`).
|
||||
*/
|
||||
/** HTTP error flagged `keyRequired`, `keyRejected` or `retryAfterSec` (429). */
|
||||
export class MapillarySourceError extends Error {
|
||||
constructor(message, { status = 0, payload = null, retryAfterSec } = {}) {
|
||||
super(message);
|
||||
@@ -44,10 +40,8 @@ async function readJsonOrThrow(response, label) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every network path the Mapillary layer uses. Coverage tiles go through the
|
||||
* local dev-server proxy (which adds the token and caches them); image and
|
||||
* sequence lookups go straight to graph.mapillary.com with the client token,
|
||||
* exactly as the embedded MapillaryJS viewer does.
|
||||
* Mapillary network access: tiles via the local proxy, which adds the token
|
||||
* and caches; Graph API lookups direct with the client token, as MapillaryJS.
|
||||
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
|
||||
*/
|
||||
export function createMapillarySource({
|
||||
|
||||
@@ -110,7 +110,7 @@ test('the source exposes only imagery lookups', () => {
|
||||
assert.equal(source[gone], undefined, gone);
|
||||
});
|
||||
|
||||
test('a rejected key and a rate limit are told apart from other tile errors (review IC8 P2)', async () => {
|
||||
test('a rejected key and a rate limit are told apart from other tile errors', async () => {
|
||||
const rejectedSource = createMapillarySource({
|
||||
token: 'MLY|1|abc',
|
||||
fetchImpl: fakeFetch(() =>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
|
||||
|
||||
/**
|
||||
* The Mapillary viewer adapter: lazy-load MapillaryJS, mount it in the host
|
||||
* the core hands over, open images, and emit a provider-neutral pose for
|
||||
* every image, point-of-view or position change.
|
||||
* Viewer adapter: lazy-loads MapillaryJS into the core's host and emits a
|
||||
* provider-neutral pose on every image, view or position change.
|
||||
* @returns {import('../../registry.js').ViewerAdapter}
|
||||
*/
|
||||
export function createMapillaryViewer({ source, render } = {}) {
|
||||
@@ -11,7 +10,6 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
let Library = null;
|
||||
let container = null;
|
||||
let pendingOpen = null;
|
||||
/** The prewarm in flight, if any. */
|
||||
let prewarming = null;
|
||||
/** In-flight viewer construction, so pre-warm and open never build two. */
|
||||
let creating = null;
|
||||
@@ -34,10 +32,8 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
|
||||
async function ensureLibrary() {
|
||||
if (Library) return Library;
|
||||
// The stylesheet comes with the lazy chunk: Vite applies a dynamically
|
||||
// imported stylesheet before its import resolves, so the viewer's chrome
|
||||
// is styled from the first frame without costing every page load ~90 KB
|
||||
// of render-blocking CSS.
|
||||
// Lazy CSS: Vite applies it before the import resolves, so the viewer is
|
||||
// styled from its first frame without ~90 KB of render-blocking CSS.
|
||||
const [library] = await Promise.all([
|
||||
import('mapillary-js'),
|
||||
import('mapillary-js/dist/mapillary.css'),
|
||||
@@ -196,14 +192,11 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load the library and stand the viewer up ahead of the first image, so
|
||||
* opening one only costs the image download. Safe to call repeatedly.
|
||||
*/
|
||||
/** Build the viewer ahead of the first image. Safe to call repeatedly. */
|
||||
async prewarm(host) {
|
||||
if (!host) return;
|
||||
// A prewarm already in flight may give up (the layer went off and on
|
||||
// while the library downloaded): wait for it, then build if it did not.
|
||||
// An in-flight prewarm may give up (layer toggled mid-download): wait
|
||||
// for it, then build if it did not.
|
||||
if (prewarming) await prewarming;
|
||||
if (viewer) return;
|
||||
const run = (async () => {
|
||||
|
||||
@@ -7,17 +7,17 @@
|
||||
* @property {string} label Chip text and link label, e.g. 'MAPILLARY'.
|
||||
* @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless.
|
||||
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
|
||||
* @property {{coverage: string}} colors The source's one colour (policy.js PROVIDER_COLORS): chip, lines, points, cones and legend.
|
||||
* @property {{coverage: string}} colors The source's one colour, from policy.js PROVIDER_COLORS.
|
||||
* @property {{html: string}} credit On-globe attribution while the provider is active.
|
||||
* @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, terrain.
|
||||
* @property {() => {pano: string, sinceMs: number|null}} getFilter The imagery filter resolved against now: read it whenever filtering, so a "since N days" window keeps up with the clock.
|
||||
* @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up.
|
||||
* @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 {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth.
|
||||
* @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service.
|
||||
* @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js) that refine the cast; pass `meshAt` to `castLine`.
|
||||
* @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`.
|
||||
* @property {() => void} notify Ask the core to publish a new UI snapshot.
|
||||
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
|
||||
*
|
||||
@@ -81,8 +81,7 @@ const REQUIRED = Object.freeze([
|
||||
const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
/**
|
||||
* Check a provider list once at construction and freeze its order, which is
|
||||
* also the chip order in the panel.
|
||||
* Validate providers and freeze their order, which is also the chip order.
|
||||
* @param {Array<StreetLevelProvider>} providers
|
||||
* @returns {ReadonlyArray<StreetLevelProvider>}
|
||||
*/
|
||||
@@ -130,9 +129,8 @@ export function validateProviders(providers) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The layer-level key requirement: the one key id every provider shares, or
|
||||
* null as soon as any provider is keyless or they differ. Per-provider needs
|
||||
* are then reported chip by chip instead of gating the whole layer.
|
||||
* The key id every provider shares, or null if any is keyless or they differ
|
||||
* (then each chip reports its own need).
|
||||
* @param {ReadonlyArray<StreetLevelProvider>} providers
|
||||
* @returns {string|null}
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||
|
||||
/**
|
||||
* Minimum time between hover picks: ~8 `scene.pick` calls a second while the
|
||||
* pointer moves, as the CCTV layer's hover does, never one per frame.
|
||||
*/
|
||||
/** Hover picks at most ~8 times a second (like the CCTV layer), never per frame. */
|
||||
const HOVER_PICK_INTERVAL_MS = 120;
|
||||
|
||||
/** One click/hover handler for every provider's coverage and image cones. */
|
||||
@@ -92,9 +89,8 @@ export function createSelection({ state, parts }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer cursor over anything this layer owns, so lines read as clickable.
|
||||
* Picks the latest position at most every HOVER_PICK_INTERVAL_MS; a move
|
||||
* inside the interval is picked when it ends, so the cursor never sticks.
|
||||
* Pointer cursor over anything this layer owns. Throttled; a move inside
|
||||
* the interval is picked when it ends, so the cursor never sticks.
|
||||
*/
|
||||
function onMove(movement) {
|
||||
const viewer = state.viewer;
|
||||
@@ -119,10 +115,8 @@ export function createSelection({ state, parts }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Track held buttons (a drag picks nothing) and camera stops. A moving
|
||||
* camera does not stop hover: an orbiting or tracking camera never stops,
|
||||
* and the throttle already bounds the cost. When the camera comes to rest
|
||||
* the pointer is picked once more, since what was under it has moved.
|
||||
* Track held buttons (a drag picks nothing) and re-pick when the camera
|
||||
* rests. Hover keeps running while it moves: a tracking camera never stops.
|
||||
*/
|
||||
function watchHover(viewer) {
|
||||
const canvas = viewer.scene.canvas;
|
||||
|
||||
@@ -155,7 +155,7 @@ test('hover does not pick while a mouse button is held', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('a camera that never stops (orbit, tracking) keeps hover throttled, not off (review P3)', () => {
|
||||
test('a camera that never stops (orbit, tracking) keeps hover throttled, not off', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
// An orbiting or tracking camera raises moveStart and never moveEnd.
|
||||
@@ -171,7 +171,7 @@ test('a camera that never stops (orbit, tracking) keeps hover throttled, not off
|
||||
}
|
||||
});
|
||||
|
||||
test('when the camera stops, the pointer is picked again so the cursor cannot stick (review P3)', () => {
|
||||
test('when the camera stops, the pointer is picked again so the cursor cannot stick', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.scene.under = 'mly:seq:1';
|
||||
@@ -204,9 +204,8 @@ test('a hover frame queued before the layer went off does not pick', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* An element as Esc sees it: `closest` matches the tag names a selector list
|
||||
* names, and `isContentEditable` is what the browser reports, inherited from
|
||||
* an editing host.
|
||||
* An element as Esc sees it: `closest` matches tag names in a selector list;
|
||||
* `isContentEditable` is inherited from an editing host, as in a browser.
|
||||
*/
|
||||
function element(tag, { isContentEditable = false } = {}) {
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { FILTER_DEFAULT } from './policy.js';
|
||||
|
||||
/**
|
||||
* The open image's fields of `state.street`, as they are with no image open.
|
||||
* Closing the viewer resets exactly these; the panel host, the render mode
|
||||
* and whether following is available outlive any one image.
|
||||
* The per-image fields of `state.street` with no image open; closing resets
|
||||
* exactly these (host, render mode and follow availability outlive an image).
|
||||
*/
|
||||
export function freshStreet() {
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
/**
|
||||
* Web-Mercator tile arithmetic for Mapillary vector tiles. Pure functions,
|
||||
* no Cesium dependency, shared by the browser layer and the server executor.
|
||||
*/
|
||||
/** Web-Mercator tile math, without Cesium, shared by the browser layer and the server executor. */
|
||||
|
||||
const MAX_LAT = 85.05112878;
|
||||
|
||||
@@ -9,10 +6,7 @@ function clampLat(lat) {
|
||||
return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
|
||||
}
|
||||
|
||||
/**
|
||||
* A longitude, or the difference of two, wrapped into [-180, 180]: the short
|
||||
* way round the date line. Values already in range come back unchanged.
|
||||
*/
|
||||
/** Wrap a longitude, or a difference of two, into [-180, 180]: the short way round. */
|
||||
export function wrapLon(lon) {
|
||||
if (lon >= -180 && lon <= 180) return lon;
|
||||
return ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
@@ -31,7 +25,6 @@ function tileYAt(lat, z) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Tile column for a longitude at zoom z. */
|
||||
export function lonToTileX(lon, z) {
|
||||
const n = 2 ** z;
|
||||
// 180° is the east edge of the last column, not the west edge of the first.
|
||||
@@ -39,7 +32,6 @@ export function lonToTileX(lon, z) {
|
||||
return Math.min(n - 1, Math.max(0, Math.floor(tileXAt(wrapLon(lon), z))));
|
||||
}
|
||||
|
||||
/** Tile row for a latitude at zoom z. */
|
||||
export function latToTileY(lat, z) {
|
||||
const n = 2 ** z;
|
||||
return Math.min(n - 1, Math.max(0, Math.floor(tileYAt(lat, z))));
|
||||
@@ -56,10 +48,7 @@ function tileYToLat(y, z) {
|
||||
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Geographic bounds of one tile.
|
||||
* @returns {{west:number,south:number,east:number,north:number}}
|
||||
*/
|
||||
/** @returns {{west:number,south:number,east:number,north:number}} degrees */
|
||||
export function tileBounds(x, y, z) {
|
||||
return {
|
||||
west: tileXToLon(x, z),
|
||||
@@ -69,10 +58,7 @@ export function tileBounds(x, y, z) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a bbox given as [west, south, east, north] (or an object) into
|
||||
* finite, ordered numbers. Returns null when the input is not a usable box.
|
||||
*/
|
||||
/** A [west, south, east, north] array or object as finite, ordered bounds; null if unusable. */
|
||||
export function normalizeBbox(input) {
|
||||
const values = Array.isArray(input)
|
||||
? input
|
||||
@@ -121,16 +107,10 @@ function segmentDistance2(tile, from, to, z) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate the tiles at zoom z covering a bbox (west > east means it crosses
|
||||
* the date line), ordered from the centre of
|
||||
* the box outwards so a progressive renderer fills in what the user is most
|
||||
* likely looking at first. `limit` caps the list; the result reports whether
|
||||
* the cap was hit.
|
||||
*
|
||||
* `focus` ranks by distance from the line between `focus.from` (the ground
|
||||
* under the camera) and `focus.to` (the ground at the centre of the screen)
|
||||
* instead: a tilted view's box reaches far past what the user looks at, and
|
||||
* its centre can sit kilometres ahead of both.
|
||||
* Tiles at zoom z covering a bbox (west > east crosses the date line), from
|
||||
* the centre outwards, capped at `limit`. With `focus`, tiles rank by distance
|
||||
* from the segment `from` (ground under the camera) to `to` (screen centre):
|
||||
* a tilted view's box centre can sit kilometres ahead of both.
|
||||
* @param {{limit?: number, focus?: {from: {lon: number, lat: number}, to?: {lon: number, lat: number}|null}|null}} [options]
|
||||
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
|
||||
*/
|
||||
@@ -191,11 +171,7 @@ function tileGrid(box, z) {
|
||||
return { tiles, x0, x1, y0, y1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a vector-tile-local coordinate (0..extent) into longitude and
|
||||
* latitude for tile (x, y, z).
|
||||
* @returns {[number, number]} [lon, lat]
|
||||
*/
|
||||
/** A vector-tile-local coordinate (0..extent) in tile (x, y, z) as [lon, lat]. */
|
||||
export function tileLocalToLonLat(px, py, extent, x, y, z) {
|
||||
const n = 2 ** z;
|
||||
const lon = ((x + px / extent) / n) * 360 - 180;
|
||||
@@ -205,11 +181,7 @@ export function tileLocalToLonLat(px, py, extent, x, y, z) {
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a coverage zoom for a camera height (metres above ground). Higher
|
||||
* cameras get coarser sequence tiles; below the floor Mapillary has no
|
||||
* finer tiles than z14.
|
||||
*/
|
||||
/** Sequence-tile zoom for a camera height above ground (m); Mapillary stops at z14. */
|
||||
export function coverageZoomForHeight(heightM) {
|
||||
if (!Number.isFinite(heightM)) return null;
|
||||
if (heightM > 60_000) return null;
|
||||
@@ -220,10 +192,8 @@ export function coverageZoomForHeight(heightM) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the low-zoom `overview` tile level for a camera far above the ground.
|
||||
* Mapillary publishes coverage points at z0–5; sequences start at z6 and are
|
||||
* far too heavy for a continent-sized view. Returns null below the overview
|
||||
* ceiling (60 km), where `coverageZoomForHeight` takes over.
|
||||
* Overview-point zoom (z0–5) above 60 km, where sequence tiles are too heavy;
|
||||
* null below, where `coverageZoomForHeight` takes over.
|
||||
*/
|
||||
export function overviewZoomForHeight(heightM) {
|
||||
if (!Number.isFinite(heightM) || heightM <= 60_000) return null;
|
||||
|
||||
@@ -151,7 +151,7 @@ test('a box across the date line ranks the tiles at the line first, without and
|
||||
}
|
||||
});
|
||||
|
||||
test('a focus ranks tiles along the line of sight, not from the box centre (review IC8 P1)', () => {
|
||||
test('a focus ranks tiles along the line of sight, not from the box centre', () => {
|
||||
// A box that runs far north of the camera, as a tilted view's does: the
|
||||
// box centre is kilometres from both the camera and the screen centre.
|
||||
const from = { lon: -121.4944, lat: 38.5816 };
|
||||
|
||||
@@ -18,9 +18,8 @@ import { COLORS } from './policy.js';
|
||||
*/
|
||||
|
||||
/**
|
||||
* What the switched-on providers add up to: summed counts, any loading, the
|
||||
* first hint and error, whether every one of them lacks its key, and whether
|
||||
* that is because a provider rejected the key it was given.
|
||||
* Totals over switched-on providers. Key-gated only when every one lacks its
|
||||
* key; `keyRejected` when that is because a key was refused.
|
||||
* @param {Array<ProviderSnapshot>} providers
|
||||
*/
|
||||
export function summarizeCoverage(providers) {
|
||||
@@ -40,12 +39,8 @@ export function summarizeCoverage(providers) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the snapshot the panel renders from the core state and one
|
||||
* snapshot per registered provider. Pure, so the merge rules are testable:
|
||||
* counts add up across active providers, the layer is key-gated only when
|
||||
* every switched-on provider lacks its key, and the legend lists one swatch
|
||||
* per active source, in its colour and under its name, followed by the
|
||||
* shared selection colour.
|
||||
* The panel snapshot from core state and provider snapshots. The legend has
|
||||
* one swatch per active source, then the shared selection colour.
|
||||
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
|
||||
*/
|
||||
export function composeUIState({
|
||||
|
||||
@@ -118,7 +118,7 @@ test('summarizeCoverage is what getStats reports, without building a snapshot',
|
||||
assert.equal(summarizeCoverage([]).keyRequired, false);
|
||||
});
|
||||
|
||||
test('a rejected key gates like a missing one and is named as rejected (review IC8 P2)', () => {
|
||||
test('a rejected key gates like a missing one and is named as rejected', () => {
|
||||
const rejected = provider({
|
||||
keyRequired: true,
|
||||
keyRejected: true,
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { wrapLon } from './tileMath.js';
|
||||
|
||||
/**
|
||||
* Approximate metres between two {lon, lat} points: equirectangular, good
|
||||
* for the street-scale distances the layer measures. Measured the short way
|
||||
* round, so neighbours either side of ±180° are metres apart, not a world.
|
||||
*/
|
||||
/** Equirectangular metres between {lon, lat} points, the short way round ±180°; street scale. */
|
||||
export function metresBetween(a, b) {
|
||||
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
|
||||
return Math.hypot(
|
||||
@@ -15,11 +11,8 @@ export function metresBetween(a, b) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run low-priority work when the browser is idle, within `timeout` ms; a
|
||||
* browser without idle callbacks (and Node) runs it a frame later. The task
|
||||
* gets the idle deadline, or null.
|
||||
* @param {(deadline: IdleDeadline|null) => void} task
|
||||
* @param {number} timeout
|
||||
* Run `task(deadline)` when idle, within `timeout` ms; without idle callbacks
|
||||
* (Node) it runs a frame later with a null deadline.
|
||||
*/
|
||||
export function whenIdle(task, timeout) {
|
||||
if (typeof globalThis.requestIdleCallback === 'function')
|
||||
@@ -28,10 +21,8 @@ export function whenIdle(task, timeout) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Height of the surface under the camera above the ellipsoid, in metres. The
|
||||
* globe's terrain answers when the globe is shown; Google 3D hides the globe,
|
||||
* so there the bare-earth height from `groundAt(lon, lat)` answers instead.
|
||||
* Null when neither has a sample yet.
|
||||
* Ellipsoidal height (m) of the surface under the camera: globe terrain, or
|
||||
* `groundAt` where Google 3D hides the globe; null without a sample.
|
||||
* @param {object} viewer
|
||||
* @param {{groundAt?: (lon: number, lat: number) => number|null}} [options]
|
||||
*/
|
||||
@@ -48,13 +39,7 @@ export function groundUnderCamera(viewer, { groundAt } = {}) {
|
||||
return Number.isFinite(ground) ? ground : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera height above the surface under the camera, in metres (without a
|
||||
* ground sample a camera over a city 1,600 m up would read 1,600 m too
|
||||
* high). Falls back to the ellipsoidal height when nothing has a sample yet.
|
||||
* @param {object} viewer
|
||||
* @param {{groundAt?: (lon: number, lat: number) => number|null}} [options]
|
||||
*/
|
||||
/** Camera height (m) above the ground under it (options as `groundUnderCamera`). */
|
||||
export function cameraHeightAboveGround(viewer, options = {}) {
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
@@ -90,18 +75,11 @@ function groundHit(camera, point, ellipsoid, maxRange) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible bbox as [west, south, east, north] degrees, or null when the camera
|
||||
* does not see the ground. A grid of screen rays is cast onto the ellipsoid
|
||||
* and only the rays that hit count, so a view that includes the horizon (or a
|
||||
* canvas whose frustum is stale) cannot inflate the box to the whole world;
|
||||
* `computeViewRectangle` is the fallback when too few rays land.
|
||||
*
|
||||
* `groundHeight` raises the ellipsoid to the ground under the camera: Google
|
||||
* 3D hides the globe, and a city 1,600 m up otherwise puts every hit
|
||||
* kilometres ahead of where the rays really meet the streets. `maxRange`
|
||||
* (metres) drops hits near the horizon, which a street-level view would
|
||||
* otherwise stretch into a box far bigger than anything it can show.
|
||||
* `nearRange` (metres) always includes the ground that far around the camera.
|
||||
* Visible [west, south, east, north] degrees, or null. Only screen rays that
|
||||
* hit count, so a horizon cannot inflate the box to the world.
|
||||
* `groundHeight` raises the ellipsoid to the ground (Google 3D hides the
|
||||
* globe), `maxRange` (m) drops near-horizon hits, and `nearRange` (m) always
|
||||
* includes the ground around the camera.
|
||||
*/
|
||||
export function visibleBbox(
|
||||
viewer,
|
||||
@@ -129,8 +107,7 @@ export function visibleBbox(
|
||||
}
|
||||
}
|
||||
// The ground around the camera always counts: a street-level view's screen
|
||||
// rows skip from the horizon to the first few metres, and turning the
|
||||
// camera should not wait for a reload.
|
||||
// rows skip from the horizon to the first few metres.
|
||||
const nadir = camera.positionCartographic;
|
||||
if (Number.isFinite(nearRange) && nearRange > 0 && nadir) {
|
||||
const dLat = nearRange / 111_320;
|
||||
@@ -212,10 +189,8 @@ export function viewCentre(viewer) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The ground the camera is looking at, for ranking coverage tiles: the point
|
||||
* under the camera (`nadir`) and the ground hit at the centre of the screen
|
||||
* (`ahead`, null when the centre ray misses or is out of `maxRange`).
|
||||
* @returns {{nadir: {lon: number, lat: number}, ahead: {lon: number, lat: number}|null}|null}
|
||||
* Ground the camera looks at, for ranking tiles: `nadir` under the camera and
|
||||
* `ahead` at the screen centre (null on a miss or beyond `maxRange`).
|
||||
*/
|
||||
export function viewFocus(
|
||||
viewer,
|
||||
@@ -251,7 +226,10 @@ export function viewFocus(
|
||||
return { nadir, ahead };
|
||||
}
|
||||
|
||||
/** How far below WGS84 the horizon cull's occluder sits, in metres. */
|
||||
/**
|
||||
* Horizon occluder depth below WGS84 (m): ground can lie below the ellipsoid
|
||||
* (NYC −22 m), and an occluder at 0 m would hide the cones around it.
|
||||
*/
|
||||
export const HORIZON_CULL_DEPTH_M = 1000;
|
||||
const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid(
|
||||
Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M,
|
||||
@@ -260,18 +238,9 @@ const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid(
|
||||
);
|
||||
|
||||
/**
|
||||
* Hide billboards and points behind the horizon, as the cyclones layer does.
|
||||
* Street Level's skip the depth test, so Google 3D and clouds never hide the
|
||||
* near side's; nothing else then stops the far hemisphere's drawing through
|
||||
* the globe, and a finite skip distance cannot, as the horizon's distance
|
||||
* moves with the camera. Re-culls on pre-render only once the camera moved,
|
||||
* and stops listening when there is nothing left to cull.
|
||||
*
|
||||
* The occluder is the WGS84 ellipsoid shrunk by HORIZON_CULL_DEPTH_M, below
|
||||
* any real surface: on the ellipsoid itself a camera standing on ground that
|
||||
* lies below it (NYC −22 m, Colombo −95 m) would see every lower point as
|
||||
* behind the horizon and hide the cones around it. A kilometre changes
|
||||
* nothing for the far side of the Earth seen from orbit.
|
||||
* Hide billboards and points behind the horizon: they skip the depth test, so
|
||||
* nothing else stops the far side drawing through the globe. Re-culls on
|
||||
* pre-render after the camera moves; stops when nothing is left to cull.
|
||||
* @param {{getViewer: () => object|null, items: () => Iterable<{position: object, show: boolean}>, onChange?: () => void}} options
|
||||
*/
|
||||
export function createHorizonCull({ getViewer, items, onChange }) {
|
||||
@@ -308,10 +277,7 @@ export function createHorizonCull({ getViewer, items, onChange }) {
|
||||
if (!cullAll(position)) stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cull `added` (new or moved items), or everything, now; then everything
|
||||
* again whenever the camera moves.
|
||||
*/
|
||||
/** Cull `added` items (or all) now, then all again whenever the camera moves. */
|
||||
function update(added) {
|
||||
const viewer = getViewer();
|
||||
const position = viewer?.camera?.positionWC;
|
||||
|
||||
@@ -103,7 +103,7 @@ function pinhole({ lon, lat, ground, agl, pitch, w = 1600, h = 900 }) {
|
||||
};
|
||||
}
|
||||
|
||||
test('a tilted street view over high ground boxes the streets it looks at, not the horizon (review IC8 P1)', () => {
|
||||
test('a tilted street view over high ground boxes the streets it looks at, not the horizon', () => {
|
||||
// Denver, 600 m above a street 1,610 m up, looking 20° down: the centre of
|
||||
// the screen meets the street about 1.65 km north of the camera.
|
||||
const view = pinhole({
|
||||
@@ -115,8 +115,8 @@ test('a tilted street view over high ground boxes the streets it looks at, not t
|
||||
});
|
||||
const ahead = 39.74 + 1648 / 111_000;
|
||||
const unranged = visibleBbox(view);
|
||||
// Rays to the bare ellipsoid travel 1.6 km further down: the old box was
|
||||
// 20 km wide and started past the street at the centre of the screen.
|
||||
// Without options, rays to the bare ellipsoid travel 1.6 km further down:
|
||||
// the box is 20 km wide and starts past the street at the screen centre.
|
||||
assert.ok(unranged[2] - unranged[0] > 0.2);
|
||||
assert.ok(unranged[1] > ahead, 'the old box missed the street in view');
|
||||
const options = { groundHeight: 1610, maxRange: 6000, nearRange: 1000 };
|
||||
@@ -149,7 +149,7 @@ test('looking at the horizon from eye height still boxes the ground around the c
|
||||
assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008);
|
||||
});
|
||||
|
||||
test('metresBetween measures the short way round the date line (review IC8 P2)', () => {
|
||||
test('metresBetween measures the short way round the date line', () => {
|
||||
const east = { lon: 179.9999, lat: 0 };
|
||||
const west = { lon: -179.9999, lat: 0 };
|
||||
// 0.0002° of longitude at the equator, not 40,000 km round the other way.
|
||||
@@ -284,7 +284,7 @@ function cullFrom(camera, points) {
|
||||
return items.map((item) => item.show);
|
||||
}
|
||||
|
||||
test('cones and the marker stay visible on ground below the WGS84 ellipsoid (gekh P2)', () => {
|
||||
test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => {
|
||||
// NYC in FOLLOW: eye 2.4 m above ground at -22 m, a cone 10 m away.
|
||||
assert.deepEqual(
|
||||
cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]),
|
||||
@@ -301,14 +301,14 @@ test('cones and the marker stay visible on ground below the WGS84 ellipsoid (gek
|
||||
),
|
||||
[true, true],
|
||||
);
|
||||
// Austin, ground well above the ellipsoid: as before.
|
||||
// Austin, ground well above the ellipsoid.
|
||||
assert.deepEqual(
|
||||
cullFrom([-97.7431, 30.2672, 132], [[-97.7431, 30.2681, 130]]),
|
||||
[true],
|
||||
);
|
||||
});
|
||||
|
||||
test('the horizon cull still hides the far side of the Earth from orbit (gekh P2)', () => {
|
||||
test('the horizon cull still hides the far side of the Earth from orbit', () => {
|
||||
assert.deepEqual(
|
||||
cullFrom(
|
||||
[0, 0, 20_000_000],
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { freshStreet } from './state.js';
|
||||
|
||||
/**
|
||||
* Own the one viewer element in the panel and whichever provider's viewer
|
||||
* adapter is mounted in it. Poses the adapter emits become `state.street`,
|
||||
* move the marker and (optionally) the globe camera.
|
||||
* Owns the panel's viewer element and the provider adapter mounted in it.
|
||||
* Adapter poses update `state.street`, the marker and the follow camera.
|
||||
*/
|
||||
export function createViewerHost({ state, parts }) {
|
||||
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
|
||||
@@ -11,11 +10,7 @@ export function createViewerHost({ state, parts }) {
|
||||
/** @type {{id: string, promise: Promise<object>}|null} */
|
||||
let mounting = null;
|
||||
let openSeq = 0;
|
||||
/**
|
||||
* Adapters stood up by `prewarm` that are not the active one: each holds a
|
||||
* live viewer (a WebGL context for MapillaryJS) until `unmount` releases it.
|
||||
* @type {Map<string, object>}
|
||||
*/
|
||||
/** Prewarmed, inactive adapters; each holds a live viewer (WebGL context) until `unmount`. */
|
||||
const warmed = new Map();
|
||||
|
||||
function notify() {
|
||||
@@ -44,9 +39,8 @@ export function createViewerHost({ state, parts }) {
|
||||
});
|
||||
parts.marker.set(state.street.position, state.street.bearing);
|
||||
parts.follow.followCamera();
|
||||
// Providers highlight the selected image's sequence on the map; mirror
|
||||
// that once the image itself is on screen, so the sequence lookup never
|
||||
// competes with the image download.
|
||||
// Select the sequence only once the image is on screen, so its lookup
|
||||
// never competes with the image download.
|
||||
if (!state.street.loading && state.street.sequenceId !== previousSequence)
|
||||
selectCurrentSequence();
|
||||
notify();
|
||||
@@ -62,10 +56,8 @@ export function createViewerHost({ state, parts }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a provider's viewer adapter in the host. The adapter becomes
|
||||
* `active` only once its mount succeeded, so a failed mount (a library
|
||||
* that did not load) is retried on the next open; concurrent opens for the
|
||||
* same provider share one mount.
|
||||
* Mount a provider's adapter. It becomes `active` only on success, so a
|
||||
* failed mount is retried on the next open; concurrent opens share one.
|
||||
*/
|
||||
function mount(entry) {
|
||||
if (active?.id === entry.def.id) return Promise.resolve(active.adapter);
|
||||
@@ -108,12 +100,7 @@ export function createViewerHost({ state, parts }) {
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an image from one provider. Resolves once the first pose is in
|
||||
* (true) or the open failed or was overtaken (false); later poses stream
|
||||
* through `state.street` as the user navigates.
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
|
||||
async function open(providerId, imageId, { frame = true } = {}) {
|
||||
const entry = providerEntry(providerId);
|
||||
if (!entry || !imageId) return false;
|
||||
@@ -153,10 +140,7 @@ export function createViewerHost({ state, parts }) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the image; the mounted adapter stays warm for the next open. The
|
||||
* globe stops flying toward it too (unmount closes, so layer off does).
|
||||
*/
|
||||
/** Close the image and stop any framing flight; the adapter stays warm. */
|
||||
function close() {
|
||||
openSeq++;
|
||||
parts.follow.cancelFraming();
|
||||
@@ -208,11 +192,7 @@ export function createViewerHost({ state, parts }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear viewers down: every one (layer disabled or destroyed), or only the
|
||||
* given provider's, active or prewarmed (that provider switched off).
|
||||
* @param {string} [providerId]
|
||||
*/
|
||||
/** Tear down every viewer, or only `providerId`'s (active or prewarmed). */
|
||||
function unmount(providerId) {
|
||||
if (
|
||||
!providerId ||
|
||||
|
||||
@@ -458,7 +458,7 @@ test('a prewarm that gave up when the layer went off and on mid-download is foll
|
||||
adapter.unmount();
|
||||
});
|
||||
|
||||
test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN (gekh P3)', async () => {
|
||||
test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN', async () => {
|
||||
const { gate, viewers } = fakeLibrary();
|
||||
gate.resolve();
|
||||
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||
@@ -467,7 +467,7 @@ test('the Mapillary viewer leaves resizing to the panel, so a hidden one never a
|
||||
adapter.unmount();
|
||||
});
|
||||
|
||||
test('closing the photo stops sequence playback in the hidden viewer (review P2-6)', async () => {
|
||||
test('closing the photo stops sequence playback in the hidden viewer', async () => {
|
||||
const { gate, viewers } = fakeLibrary();
|
||||
gate.resolve();
|
||||
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||
@@ -480,7 +480,7 @@ test('closing the photo stops sequence playback in the hidden viewer (review P2-
|
||||
assert.equal(viewers.live, 1, 'the viewer itself stays warm');
|
||||
});
|
||||
|
||||
test('closing the photo or switching the layer off stops its framing flight (review IC8 P1)', async () => {
|
||||
test('closing the photo or switching the layer off stops its framing flight', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
const { framing, host } = harness(adapter);
|
||||
await host.open('mapillary', 'a');
|
||||
|
||||
@@ -184,7 +184,7 @@ test('a collapsed Recent Imagery panel survives the share-link round trip beside
|
||||
] });
|
||||
});
|
||||
|
||||
test('a collapsed Street Level panel survives the share-link round trip (review P3)', () => {
|
||||
test('a collapsed Street Level panel survives the share-link round trip', () => {
|
||||
const manager = makeManager();
|
||||
manager.setPanelStateProvider(() => ({ specs: [
|
||||
{ id: 'cctv-panel', collapsed: false },
|
||||
|
||||
@@ -83,8 +83,7 @@ test('the keyless state gates the controls rather than leaving dead buttons', ()
|
||||
);
|
||||
assert.match(html, /<fieldset id="sl-controls"/);
|
||||
assert.match(html, /<div id="sl-provider-chips" class="sl-chips"><\/div>/);
|
||||
// The gate holds the filters only: the provider chips stay reachable, so
|
||||
// their tooltip can say how to add the key (gekh P3).
|
||||
// The gate holds the filters only, so the chips' key tooltip stays reachable.
|
||||
const gate = html.slice(
|
||||
html.indexOf('<fieldset id="sl-controls"'),
|
||||
html.indexOf('</fieldset>', html.indexOf('<fieldset id="sl-controls"')),
|
||||
@@ -168,9 +167,8 @@ test('the viewer comes first and the panel is a portable, resizable window', ()
|
||||
panelCss,
|
||||
/\.panel-floating\[style\*='height'\] \.sl-settings \{[^}]*overflow-y: auto/,
|
||||
);
|
||||
// Shrinking docks it back at its default size rather than leaving a
|
||||
// window over the globe: collapsing a floating panel, or SHRINK / Esc on
|
||||
// the expanded viewer.
|
||||
// Collapsing a floating panel, or SHRINK / Esc on the expanded viewer,
|
||||
// docks it rather than leaving a window over the globe.
|
||||
assert.match(
|
||||
position,
|
||||
/id: 'street-level-panel',[\s\S]*?dockOnCollapse: true/,
|
||||
@@ -191,8 +189,6 @@ test('the viewer comes first and the panel is a portable, resizable window', ()
|
||||
);
|
||||
});
|
||||
|
||||
/* ── gekh review on #768 ───────────────────────────────────────────────── */
|
||||
|
||||
test('Clean View, recording and the cockpit hide the expanded viewer on <body> (P2-5)', () => {
|
||||
// EXPAND moves the viewer out of every panel, past the panel-hiding rules.
|
||||
assert.match(
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* Shared stand-ins for the Street Level tests: a provider snapshot as the
|
||||
* core reports it, the smallest provider definition the core accepts, and a
|
||||
* camera whose screen rays really meet the ellipsoid they are given.
|
||||
*/
|
||||
/** Shared Street Level test stand-ins: provider snapshot, minimal provider, ray-casting camera. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js';
|
||||
|
||||
@@ -28,10 +24,8 @@ export function providerSnapshot(overrides = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The smallest provider definition the core accepts. `stats` is what
|
||||
* coverageStats() answers (mutate it to change the answer), `calls` counts
|
||||
* what the core asked of the provider and its viewer, `filters` lists every
|
||||
* filter it was given and `context()` is the context the core created it with.
|
||||
* The smallest provider the core accepts. Mutate `stats` to change what
|
||||
* coverageStats() answers; `calls`, `filters` and `context()` record use.
|
||||
*/
|
||||
export function fakeStreetLevelProvider({
|
||||
id = 'mapillary',
|
||||
@@ -90,10 +84,8 @@ export function fakeStreetLevelProvider({
|
||||
}
|
||||
|
||||
/**
|
||||
* A pinhole camera at `altitude` metres above the WGS84 ellipsoid, looking
|
||||
* along `heading` (degrees from north) and `pitch` (degrees, negative is
|
||||
* down), over a `width`×`height` canvas with the given fields of view. Its
|
||||
* `pickEllipsoid` intersects whatever ellipsoid it is given, as Cesium's does.
|
||||
* A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and
|
||||
* `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets.
|
||||
*/
|
||||
export function rayCamera({
|
||||
lon,
|
||||
|
||||
@@ -28,13 +28,11 @@ import {
|
||||
_settleTileWritesForTest,
|
||||
} from '../../server/providers/mapillary/tiles.js';
|
||||
|
||||
// Every tile these tests cache lands in a throwaway directory, never in the
|
||||
// developer's .gev-cache.
|
||||
// Keep test tiles out of the developer's .gev-cache.
|
||||
const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gev-mly-tiles-'));
|
||||
_setTileCacheDirForTest(cacheDir);
|
||||
// The directory is not switched back: a background write still landing must
|
||||
// never sweep the real cache. Background writes and sweeps settle first, so
|
||||
// none lands in the directory while it is being removed.
|
||||
// Not switched back, so a late background write can never sweep the real
|
||||
// cache; writes settle before the directory is removed.
|
||||
after(async () => {
|
||||
await tiles._settleTileWritesForTest();
|
||||
await fsp.rm(cacheDir, { recursive: true, force: true });
|
||||
@@ -43,7 +41,6 @@ const tileFile = ({ z, x, y }, root = cacheDir) =>
|
||||
path.join(root, 'coverage', String(z), `${x}-${y}.pbf`);
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
/** Write a cache file dated `ageMs` ago. */
|
||||
async function writeAged(file, bytes, ageMs) {
|
||||
await fsp.mkdir(path.dirname(file), { recursive: true });
|
||||
await fsp.writeFile(file, bytes);
|
||||
@@ -67,9 +64,8 @@ const exists = (file) =>
|
||||
);
|
||||
|
||||
/**
|
||||
* Mount the plugin and return callers keyed by route. `start` returns at once
|
||||
* with the response, its `close` trigger and the handler's `done` promise;
|
||||
* `call` waits for the handler.
|
||||
* Mount the plugin. `call` waits for the handler; `start` returns at once with
|
||||
* the response, its `close` trigger and the handler's `done` promise.
|
||||
*/
|
||||
function install(plugin, mode = 'configureServer') {
|
||||
const routes = new Map();
|
||||
@@ -186,11 +182,7 @@ test('tile route validates the path and refuses to proxy without a token', async
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Drive the tile route against a scripted upstream: `answer(call, url)`
|
||||
* returns the Response for each upstream fetch; `inits` holds each fetch's
|
||||
* options. Refusals are never cached, so no disk tile is written.
|
||||
*/
|
||||
/** Drive the tile route against a scripted upstream `answer(call, url)`. */
|
||||
async function withUpstream(answer, run) {
|
||||
const savedFetch = globalThis.fetch;
|
||||
const savedToken = process.env.MAPILLARY_CLIENT_TOKEN;
|
||||
@@ -217,7 +209,7 @@ async function withUpstream(answer, run) {
|
||||
}
|
||||
|
||||
for (const status of [401, 403])
|
||||
test(`an upstream ${status} is a rejected key, and misses stop asking Mapillary (review IC8 P2)`, async () => {
|
||||
test(`an upstream ${status} is a rejected key, and misses stop asking Mapillary`, async () => {
|
||||
await withUpstream(
|
||||
() => new Response('{}', { status }),
|
||||
async ({ calls, call }) => {
|
||||
@@ -239,7 +231,7 @@ for (const status of [401, 403])
|
||||
);
|
||||
});
|
||||
|
||||
test('a 429 passes its Retry-After on and holds misses until it is over (review IC8 P2)', async (t) => {
|
||||
test('a 429 passes its Retry-After on and holds misses until it is over', async (t) => {
|
||||
await withUpstream(
|
||||
() => new Response('{}', { status: 429, headers: { 'Retry-After': '30' } }),
|
||||
async ({ calls, call }) => {
|
||||
@@ -335,7 +327,7 @@ test('normalizeTileAddress refuses prototype keys as layer names', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('coverage zooms the app never requests (z6–10) are refused with a 400 (review P2)', async () => {
|
||||
test('coverage zooms the app never requests (z6–10) are refused with a 400', async () => {
|
||||
for (const z of [6, 7, 8, 9, 10])
|
||||
assert.throws(
|
||||
() => normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }),
|
||||
@@ -429,7 +421,7 @@ test('a tile held in memory past the 24 h TTL is fetched again', async (t) => {
|
||||
});
|
||||
});
|
||||
|
||||
test('a disk tile keeps its age in memory and through a retrim rewrite (review P2)', async (t) => {
|
||||
test('a disk tile keeps its age in memory and through a retrim rewrite', async (t) => {
|
||||
const address = { layer: 'coverage', z: 14, x: 8, y: 8 };
|
||||
const file = tileFile(address);
|
||||
const untrimmed = tile([
|
||||
@@ -476,7 +468,7 @@ async function withCacheDir(run) {
|
||||
}
|
||||
}
|
||||
|
||||
test('the disk sweep removes expired tiles and keeps fresh ones (review P2)', async () => {
|
||||
test('the disk sweep removes expired tiles and keeps fresh ones', async () => {
|
||||
await withCacheDir(async (dir) => {
|
||||
const expired = tileFile({ z: 14, x: 1, y: 1 }, dir);
|
||||
const fresh = tileFile({ z: 14, x: 2, y: 2 }, dir);
|
||||
@@ -489,7 +481,7 @@ test('the disk sweep removes expired tiles and keeps fresh ones (review P2)', as
|
||||
});
|
||||
});
|
||||
|
||||
test('the disk sweep evicts the oldest tiles past the size cap (review P2)', async () => {
|
||||
test('the disk sweep evicts the oldest tiles past the size cap', async () => {
|
||||
await withCacheDir(async (dir) => {
|
||||
const files = [3, 2, 1].map((age) => {
|
||||
const file = tileFile({ z: 13, x: age, y: age }, dir);
|
||||
@@ -506,7 +498,7 @@ test('the disk sweep evicts the oldest tiles past the size cap (review P2)', asy
|
||||
});
|
||||
});
|
||||
|
||||
test('caching a tile sweeps expired files off the disk in the background (review P2)', async () => {
|
||||
test('caching a tile sweeps expired files off the disk in the background', async () => {
|
||||
await withCacheDir(async (dir) => {
|
||||
const expired = tileFile({ z: 12, x: 1, y: 1 }, dir);
|
||||
const fresh = tileFile({ z: 12, x: 2, y: 2 }, dir);
|
||||
@@ -525,11 +517,7 @@ test('caching a tile sweeps expired files off the disk in the background (review
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A deferred upstream for in-flight tests: every fetch waits until released,
|
||||
* and records whether its signal was aborted. Their disk files are removed
|
||||
* first.
|
||||
*/
|
||||
/** An upstream whose fetches wait until released and record their abort. */
|
||||
async function withDeferredUpstream(tiles, run) {
|
||||
const savedFetch = globalThis.fetch;
|
||||
const savedToken = process.env.MAPILLARY_CLIENT_TOKEN;
|
||||
@@ -681,7 +669,7 @@ test('a request arriving before a cancelled fetch has wound down starts a fresh
|
||||
});
|
||||
});
|
||||
|
||||
// ── Route: success path, refusals, gate and limiter (review gekh) ──
|
||||
// ── Route: success path, refusals, gate and limiter ──
|
||||
|
||||
/** Every header value and the body, as text, for a token-leak check. */
|
||||
const leakText = (res) =>
|
||||
@@ -690,7 +678,7 @@ const leakText = (res) =>
|
||||
Buffer.isBuffer(res.body) ? res.body.toString('latin1') : String(res.body),
|
||||
].join('\n');
|
||||
|
||||
test('the tile route serves a trimmed protobuf tile, then from memory (review gekh P0)', async () => {
|
||||
test('the tile route serves a trimmed protobuf tile, then from memory', async () => {
|
||||
const address = { layer: 'coverage', z: 14, x: 9, y: 9 };
|
||||
await fsp.rm(tileFile(address), { force: true });
|
||||
const upstream = tile([
|
||||
@@ -723,7 +711,7 @@ test('the tile route serves a trimmed protobuf tile, then from memory (review ge
|
||||
});
|
||||
|
||||
for (const status of [204, 404])
|
||||
test(`an empty upstream tile (${status}) is a 204 with no body (review gekh P0)`, async () => {
|
||||
test(`an empty upstream tile (${status}) is a 204 with no body`, async () => {
|
||||
const address = { layer: 'coverage', z: 14, x: 10, y: status };
|
||||
await fsp.rm(tileFile(address), { force: true });
|
||||
await withUpstream(
|
||||
@@ -742,7 +730,7 @@ for (const status of [204, 404])
|
||||
);
|
||||
});
|
||||
|
||||
test('closing the response mid-flight aborts the upstream fetch (review gekh P0)', async () => {
|
||||
test('closing the response mid-flight aborts the upstream fetch', async () => {
|
||||
const address = { layer: 'coverage', z: 14, x: 11, y: 11 };
|
||||
await withDeferredUpstream([address], async ({ upstream }) => {
|
||||
const { start } = install(mapillaryProxy());
|
||||
@@ -757,7 +745,7 @@ test('closing the response mid-flight aborts the upstream fetch (review gekh P0)
|
||||
});
|
||||
|
||||
for (const status of [400, 410, 422])
|
||||
test(`an upstream ${status} is a 502, not the client's fault (review gekh)`, async () => {
|
||||
test(`an upstream ${status} is a 502, not the client's fault`, async () => {
|
||||
await withUpstream(
|
||||
() => new Response('{}', { status }),
|
||||
async ({ call }) => {
|
||||
@@ -776,7 +764,7 @@ const redirect = (location) =>
|
||||
headers: location === null ? {} : { location },
|
||||
});
|
||||
|
||||
test('a redirect within the Mapillary tile origin is followed by hand (IC8 P2)', async () => {
|
||||
test('a redirect within the Mapillary tile origin is followed by hand', async () => {
|
||||
await fsp.rm(tileFile({ z: 14, x: 13, y: 13 }), { force: true });
|
||||
await withUpstream(
|
||||
(n) =>
|
||||
@@ -808,7 +796,7 @@ for (const [why, location] of [
|
||||
['a scheme-relative host', '//evil.example/t'],
|
||||
['no Location', null],
|
||||
])
|
||||
test(`a redirect off the tile origin (${why}) is a 502, and the token stays home (IC8 P2)`, async () => {
|
||||
test(`a redirect off the tile origin (${why}) is a 502, and the token stays home`, async () => {
|
||||
await withUpstream(
|
||||
(n) =>
|
||||
n === 1
|
||||
@@ -827,7 +815,7 @@ for (const [why, location] of [
|
||||
);
|
||||
});
|
||||
|
||||
test('an endless same-origin redirect stops after a few hops (IC8 P2)', async () => {
|
||||
test('an endless same-origin redirect stops after a few hops', async () => {
|
||||
await withUpstream(
|
||||
(n) => redirect(`/maps/vtp/mly1_public/2/14/15/15?hop=${n}`),
|
||||
async ({ calls, call }) => {
|
||||
@@ -841,7 +829,7 @@ test('an endless same-origin redirect stops after a few hops (IC8 P2)', async ()
|
||||
);
|
||||
});
|
||||
|
||||
test('cross-site requests are refused on both routes; the app itself passes (review gekh)', async () => {
|
||||
test('cross-site requests are refused on both routes; the app itself passes', async () => {
|
||||
await withUpstream(
|
||||
() => new Response(tile([{ name: 'sequence' }]), { status: 200 }),
|
||||
async ({ calls, call }) => {
|
||||
@@ -893,7 +881,7 @@ test('cross-site requests are refused on both routes; the app itself passes (rev
|
||||
);
|
||||
});
|
||||
|
||||
test('the tile route answers 429 past its per-IP budget (review gekh)', async () => {
|
||||
test('the tile route answers 429 past its per-IP budget', async () => {
|
||||
const saved = process.env.MAPILLARY_CLIENT_TOKEN;
|
||||
delete process.env.MAPILLARY_CLIENT_TOKEN;
|
||||
try {
|
||||
@@ -917,9 +905,9 @@ test('the tile route answers 429 past its per-IP budget (review gekh)', async ()
|
||||
}
|
||||
});
|
||||
|
||||
// ── Tile cache and upstream bounds (review gekh) ──
|
||||
// ── Tile cache and upstream bounds ──
|
||||
|
||||
test('concurrent disk hits on an untrimmed tile rewrite it once, keeping its age (review gekh)', async (t) => {
|
||||
test('concurrent disk hits on an untrimmed tile rewrite it once, keeping its age', async (t) => {
|
||||
const address = { layer: 'coverage', z: 14, x: 14, y: 14 };
|
||||
const file = tileFile(address);
|
||||
await writeAged(
|
||||
@@ -956,7 +944,7 @@ test('concurrent disk hits on an untrimmed tile rewrite it once, keeping its age
|
||||
assert.ok(Math.abs(stat.mtimeMs - writtenAt) < 1000, 'no fresh 24 h of life');
|
||||
});
|
||||
|
||||
test('a rewrite overtaken by a fresh tile write steps aside cleanly (review gekh)', async (t) => {
|
||||
test('a rewrite overtaken by a fresh tile write steps aside cleanly', async (t) => {
|
||||
const address = { layer: 'coverage', z: 14, x: 15, y: 15 };
|
||||
const file = tileFile(address);
|
||||
await writeAged(
|
||||
@@ -1012,7 +1000,7 @@ test('a rewrite overtaken by a fresh tile write steps aside cleanly (review gekh
|
||||
);
|
||||
});
|
||||
|
||||
test('at most a few upstream fetches run at once; a queued abort never fetches (review gekh)', async () => {
|
||||
test('at most a few upstream fetches run at once; a queued abort never fetches', async () => {
|
||||
const addresses = Array.from(
|
||||
{ length: TILE_UPSTREAM_CONCURRENCY + 2 },
|
||||
(_, i) => ({
|
||||
@@ -1058,7 +1046,7 @@ test('at most a few upstream fetches run at once; a queued abort never fetches (
|
||||
});
|
||||
});
|
||||
|
||||
test('a caller gone while the disk cache is read starts no upstream fetch (review gekh)', async () => {
|
||||
test('a caller gone while the disk cache is read starts no upstream fetch', async () => {
|
||||
const address = { layer: 'coverage', z: 13, x: 120, y: 100 };
|
||||
await withDeferredUpstream([address], async ({ upstream, settle }) => {
|
||||
const controller = new AbortController();
|
||||
@@ -1070,7 +1058,7 @@ test('a caller gone while the disk cache is read starts no upstream fetch (revie
|
||||
});
|
||||
});
|
||||
|
||||
test('empty tiles are charged against the memory budget (review gekh)', async () => {
|
||||
test('empty tiles are charged against the memory budget', async () => {
|
||||
const addresses = [16, 17, 18].map((x) => ({
|
||||
layer: 'coverage',
|
||||
z: 14,
|
||||
@@ -1092,7 +1080,7 @@ test('empty tiles are charged against the memory budget (review gekh)', async ()
|
||||
);
|
||||
});
|
||||
|
||||
/** A one-layer tile of exactly `size` bytes (a big opaque payload). */
|
||||
/** A one-layer tile of exactly `size` bytes. */
|
||||
function tileOfSize(size) {
|
||||
const probe = tile([{ name: 'sequence', payload: Buffer.alloc(size) }]);
|
||||
const bytes = tile([
|
||||
@@ -1102,10 +1090,7 @@ function tileOfSize(size) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve each address's big tile from a scripted upstream, with disk writes
|
||||
* off: a tile memory dropped can only come back from Mapillary.
|
||||
*/
|
||||
/** Disk writes are off, so a tile memory dropped comes back from Mapillary. */
|
||||
async function withBigTiles(t, bodies, run) {
|
||||
for (const address of bodies.keys())
|
||||
await fsp.rm(tileFile(address), { force: true });
|
||||
@@ -1212,7 +1197,7 @@ test('a tile costing more than half the memory budget is served but not kept', a
|
||||
);
|
||||
});
|
||||
|
||||
test('empty tile files count toward the disk cap (review gekh)', async () => {
|
||||
test('empty tile files count toward the disk cap', async () => {
|
||||
await withCacheDir(async (dir) => {
|
||||
const files = [3, 2, 1].map((age) => ({
|
||||
file: tileFile({ z: 13, x: age, y: age }, dir),
|
||||
@@ -1232,7 +1217,7 @@ test('empty tile files count toward the disk cap (review gekh)', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('a body past the size cap aborts the upstream request as it streams (review gekh)', async () => {
|
||||
test('a body past the size cap aborts the upstream request as it streams', async () => {
|
||||
const address = { layer: 'coverage', z: 14, x: 19, y: 19 };
|
||||
await fsp.rm(tileFile(address), { force: true });
|
||||
const MB = 1024 * 1024;
|
||||
@@ -1279,7 +1264,7 @@ test('a body past the size cap aborts the upstream request as it streams (review
|
||||
}
|
||||
});
|
||||
|
||||
// ── Tile trimming (relocated from server/providers/mapillary/trim.test.mjs) ──
|
||||
// ── Tile trimming ──
|
||||
/** Build a minimal MVT: layers with a name, a version and one opaque feature. */
|
||||
function tile(layers) {
|
||||
const writer = new PbfWriter();
|
||||
|
||||
@@ -110,7 +110,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
|
||||
['/api/gbfs/', 400],
|
||||
['/api/tomtom/status', 200],
|
||||
// The Street Level QA gate intercepts these in the browser; this is
|
||||
// the check that the real servers register them (review IC8 P2).
|
||||
// the check that the real servers register them.
|
||||
['/api/mapillary/status', 200],
|
||||
['/api/mapillary/tiles/coverage/14/1/2', 503],
|
||||
['/api/radio/unknown', 404],
|
||||
|
||||
@@ -78,7 +78,7 @@ test('fixture tiles give the hermetic gate something real to filter', async () =
|
||||
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nothing in between');
|
||||
});
|
||||
|
||||
/* ── The real status route probe (review IC8 P2) ───────────────────────── */
|
||||
/* ── The real status route probe ─────────────────────────────────────── */
|
||||
|
||||
test('the gate requires the server’s real Mapillary status route', async () => {
|
||||
const { assertRealStatusRoute } =
|
||||
@@ -124,14 +124,12 @@ test('the gate requires the server’s real Mapillary status route', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
/* ── Header press-and-verify shared with the panel gate (review IC8 P2) ── */
|
||||
/* ── Header press-and-verify shared with the panel gate ───────────────── */
|
||||
|
||||
/**
|
||||
* A page stand-in whose `evaluate` runs the page function in Node against a
|
||||
* fake document. The hit check always sees the header; `presses` says where
|
||||
* each real pointerdown lands ('header', or 'other' when the rail moved the
|
||||
* panel between the check and the press). A drag from the header lifts the
|
||||
* panel and a header double-click docks it, unless `stuck`.
|
||||
* A page stand-in that runs page functions in Node against a fake document.
|
||||
* The hit check always sees the header; `presses` says where each pointerdown
|
||||
* lands ('header', 'other', 'moved' or 'none').
|
||||
*/
|
||||
function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) {
|
||||
const state = { floating, downs: 0, last: null, dragging: false, shift: 0 };
|
||||
@@ -164,7 +162,6 @@ function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) {
|
||||
const window = {
|
||||
addEventListener: (type, listener) => listeners.push(listener),
|
||||
};
|
||||
/** Run page code (an evaluated function or a page listener) in the page. */
|
||||
const inPage = (fn, ...args) => {
|
||||
const saved = { document: globalThis.document, window: globalThis.window };
|
||||
Object.assign(globalThis, { document, window });
|
||||
|
||||
@@ -954,8 +954,7 @@ export class StyleManager extends ShellFacade {
|
||||
this.setPanelCollapsed('street-level-panel', collapsed, options),
|
||||
dockPanel: () => this._panelChrome.dockPanel('street-level-panel'),
|
||||
showToast: (message) => this._showToast(message),
|
||||
// Who asked for Street Level to switch on: only a user, voice or tool
|
||||
// request opens the panel, never a saved-state restore.
|
||||
// Lets the panel open for a user, voice or tool switch-on, not a restore.
|
||||
subscribeEnableRequests: (listener) =>
|
||||
this._dataManager?.subscribeVisibilityRequests?.((change) => {
|
||||
if (change?.layerId === 'street-level' && change.enabled)
|
||||
@@ -1509,12 +1508,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._initCockpitDisplayPortal();
|
||||
}
|
||||
|
||||
/**
|
||||
* A portable panel finished a resize or snapped back to its rail. A panel
|
||||
* with a viewport of its own refits here.
|
||||
* @param {string} panelId
|
||||
* @returns {void}
|
||||
*/
|
||||
/** A portable panel was resized or docked: refit any viewport inside it. */
|
||||
_onPanelResized(panelId) {
|
||||
if (panelId === 'cctv-panel') this._syncCctvPanelViewport();
|
||||
if (panelId === 'street-level-panel')
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ export function syncChipGroup(container, chips = [], { before = null } = {}) {
|
||||
`data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`,
|
||||
);
|
||||
set(button, 'textContent', chip.label);
|
||||
// Optional per-chip colour (a Street Level source); CSS decides its use.
|
||||
// Optional per-chip colour; CSS decides how it is used.
|
||||
const color = chip.color || '';
|
||||
if ((button.dataset.chipColor || '') !== color) {
|
||||
if (color) {
|
||||
|
||||
+4
-10
@@ -388,11 +388,7 @@ export class PanelChrome {
|
||||
this.shareLinkManager?.onPanelStateChange?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dock a floating portable panel back into its rail at its default size.
|
||||
* @param {string} panelId
|
||||
* @returns {boolean} Whether the panel was floating.
|
||||
*/
|
||||
/** @returns {boolean} Whether the panel was floating and is now docked. */
|
||||
dockPanel(panelId) {
|
||||
return this._panelPosition?.dockPanel?.(panelId) === true;
|
||||
}
|
||||
@@ -419,8 +415,7 @@ export class PanelChrome {
|
||||
panelEl.classList.contains('cyber-accordion-collapsed');
|
||||
// A user opening a Cyber rail panel owns the whole accordion, including
|
||||
// peers that were only presentation-collapsed during a saved-state restore.
|
||||
// A floating window is outside the accordion both ways: opening one
|
||||
// leaves the rail's panels as they are.
|
||||
// A floating window is outside the accordion both ways.
|
||||
if (
|
||||
explicit &&
|
||||
!restore &&
|
||||
@@ -433,7 +428,6 @@ export class PanelChrome {
|
||||
if (
|
||||
peer !== panelEl &&
|
||||
peer.matches('[data-panel-id]') &&
|
||||
// A floating window is not part of the rail's accordion.
|
||||
!peer.classList.contains('panel-floating') &&
|
||||
!peer.hidden
|
||||
) {
|
||||
@@ -562,8 +556,8 @@ export class PanelChrome {
|
||||
}
|
||||
}
|
||||
panelEl.classList.toggle('collapsed', nextCollapsed);
|
||||
// Only a user's own collapse docks a floating window; cockpit entry,
|
||||
// restores and accordion peers must not discard where they put it.
|
||||
// Only a user's own collapse docks a floating window; restores, cockpit
|
||||
// entry and accordion peers keep its place.
|
||||
if (explicit && !restore)
|
||||
this._panelPosition?.onPanelCollapsed?.(panelId, nextCollapsed);
|
||||
if (
|
||||
|
||||
@@ -377,10 +377,9 @@ test('a header press that travels under the drag threshold is a click, not a lif
|
||||
});
|
||||
|
||||
/**
|
||||
* Both portable panels wired through _initPanelDrag. Each box follows its
|
||||
* inline left/top/width/height like a fixed window (`box` holds the natural
|
||||
* size), its header is the top 36 px, and `obstacles` stand in for the
|
||||
* command dock and voice pill. ResizeObserver callbacks run on `observe()`.
|
||||
* Both portable panels wired through _initPanelDrag. Headers are the top
|
||||
* 36 px; `obstacles` stand in for the command dock and voice pill.
|
||||
* ResizeObserver callbacks run on `observe()`.
|
||||
*/
|
||||
function portableShell(f, { stored = {}, box = {}, obstacles = {} } = {}) {
|
||||
const observers = [];
|
||||
|
||||
@@ -205,8 +205,7 @@ export class PanelLayoutController {
|
||||
this._streetLevelPanel,
|
||||
]) {
|
||||
if (!panel) continue;
|
||||
// A portable panel restored as a floating window keeps the place and
|
||||
// size PanelPositionControls gave it; only docked panels are reset.
|
||||
// A floating window keeps its restored place and size.
|
||||
if (!panel.classList.contains('panel-floating')) {
|
||||
for (const property of ['top', 'right', 'bottom', 'left', 'z-index'])
|
||||
panel.style.removeProperty(property);
|
||||
|
||||
@@ -13,37 +13,29 @@ const PANEL_POSITION_STORAGE_VERSION = 'v8';
|
||||
const PANEL_Z_BASE = 100;
|
||||
const PANEL_Z_MAX = 139;
|
||||
/**
|
||||
* Lowest z a promoted (dragged or floating) panel may take: above every panel
|
||||
* docked in a rail, the highest of which is #pp-toggles at 110 (controls.css).
|
||||
* Renumbering restarts here, so a floating window never slips under DISPLAY.
|
||||
* Keep in step with `#right-context-rail > .panel-floating` in controls.css.
|
||||
* Lowest z for a promoted panel: above every docked rail panel (#pp-toggles is
|
||||
* 110). Keep in step with `#right-context-rail > .panel-floating` in controls.css.
|
||||
*/
|
||||
const PANEL_Z_FLOATING_FLOOR = PANEL_Z_BASE + 11;
|
||||
/** Pointer travel before a header press becomes a drag that lifts a portable panel out. */
|
||||
/** Pointer travel before a press becomes a drag or resize. */
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
/**
|
||||
* Two header presses this close in time and space snap a floating panel
|
||||
* back. Detected from pointerdown because the drag handler's
|
||||
* preventDefault() suppresses the mouse events a native dblclick needs.
|
||||
* Double press that snaps a floating panel back. Detected from pointerdown
|
||||
* because the drag's preventDefault() suppresses a native dblclick.
|
||||
*/
|
||||
const DOUBLE_PRESS_MS = 400;
|
||||
const DOUBLE_PRESS_SLOP_PX = 6;
|
||||
/** Viewport margin every positioned panel keeps clear. */
|
||||
const VIEWPORT_MARGIN_PX = 6;
|
||||
/**
|
||||
* Fixed chrome that paints over every panel: the rail is its own stacking
|
||||
* context (z 110), below the command dock (145) and the voice pill (150). A
|
||||
* floating window keeps its header clear of these so it can always be
|
||||
* grabbed again.
|
||||
* Fixed chrome that paints over the rail's stacking context; a floating
|
||||
* window keeps its header clear of it so it can always be grabbed again.
|
||||
*/
|
||||
const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control'];
|
||||
/** One-time hint shown the first time a portable panel leaves its rail. */
|
||||
const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`;
|
||||
const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back';
|
||||
/** Header children whose own interaction wins over a drag or a snap-back. */
|
||||
const INTERACTIVE_SELECTOR =
|
||||
'input, select, option, textarea, button, a, [role="button"]';
|
||||
/** Inline geometry a snap-back clears so the rail owns placement again. */
|
||||
const FLOATING_STYLE_PROPERTIES = [
|
||||
'left',
|
||||
'top',
|
||||
@@ -51,7 +43,6 @@ const FLOATING_STYLE_PROPERTIES = [
|
||||
'bottom',
|
||||
'width',
|
||||
'height',
|
||||
// A docked panel takes the rail's stacking, not a floating window's.
|
||||
'z-index',
|
||||
];
|
||||
|
||||
@@ -59,7 +50,6 @@ function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
/** A window extent that fits the viewport margins but never drops under its minimum. */
|
||||
function fitWindowExtent(value, minimum, viewportExtent) {
|
||||
return clamp(
|
||||
Math.round(value),
|
||||
@@ -68,11 +58,6 @@ function fitWindowExtent(value, minimum, viewportExtent) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a mouse or pen lifts and resizes a portable panel. On a phone the
|
||||
* rail is a scroll container, so a finger on a header is a swipe.
|
||||
*/
|
||||
/** Pin a panel's top-left corner at (left, top) as a fixed window would sit. */
|
||||
function placeAt(panelEl, left, top) {
|
||||
panelEl.style.left = `${left}px`;
|
||||
panelEl.style.top = `${top}px`;
|
||||
@@ -80,27 +65,19 @@ function placeAt(panelEl, left, top) {
|
||||
panelEl.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
/** On a phone the rail scrolls, so a finger on a header is a swipe, not a drag. */
|
||||
function isPrecisePointer(event) {
|
||||
const pointerType = event.pointerType || 'mouse';
|
||||
return pointerType === 'mouse' || pointerType === 'pen';
|
||||
}
|
||||
|
||||
/** Inline geometry a cancelled gesture puts back. */
|
||||
function geometryOf(panelEl) {
|
||||
const { left, top, width, height } = panelEl.style;
|
||||
return { left, top, width, height };
|
||||
}
|
||||
|
||||
export class PanelPositionControls {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {Function} options.syncPanelCollapseButton
|
||||
* @param {Function} options.layoutRightPanels
|
||||
* @param {Function} options.syncCctvPanelViewport
|
||||
* @param {Function} options.showToast
|
||||
* @param {(panelId: string) => void} [options.onPanelResized] Fires after a
|
||||
* portable panel's resize gesture ends or the panel snaps back to its rail.
|
||||
*/
|
||||
/** `onPanelResized(panelId)` runs after a portable panel moves, resizes or docks. */
|
||||
constructor({
|
||||
syncPanelCollapseButton,
|
||||
layoutRightPanels,
|
||||
@@ -117,7 +94,6 @@ export class PanelPositionControls {
|
||||
this._panelZCounter = PANEL_Z_BASE + 10;
|
||||
this._draggableResizeObserver = null;
|
||||
this._cancelDrag = null;
|
||||
/** Portable panels by id: `{ panel, handle, min: { width, height }, dockOnCollapse }`. */
|
||||
this._portablePanels = new Map();
|
||||
this._resizeHandles = [];
|
||||
this._dragInitialized = false;
|
||||
@@ -154,10 +130,7 @@ export class PanelPositionControls {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a floating window back on-screen. Its size is the user's, except
|
||||
* that a window wider or taller than the viewport shrinks to fit it.
|
||||
*/
|
||||
/** Bring a floating window on-screen; shrink it only if it exceeds the viewport. */
|
||||
_reclampPortablePanel(entry) {
|
||||
const { panel, min } = entry;
|
||||
const left = parseInt(panel.style.left, 10);
|
||||
@@ -178,15 +151,8 @@ export class PanelPositionControls {
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a floating window may sit: inside the viewport margin, with its
|
||||
* header out of the command dock and the voice pill, measured now because
|
||||
* the dock grows and shrinks. Both sit at the foot of the screen, so the
|
||||
* header goes above whichever it would land under.
|
||||
* @param {object} entry A `_portablePanels` value.
|
||||
* @param {number} left
|
||||
* @param {number} top
|
||||
* @param {{width: number, height: number, header: number}} [geometry]
|
||||
* Window size and header depth; measured when omitted.
|
||||
* Clamp a floating window inside the viewport margin with its header above
|
||||
* the command dock and voice pill, measured each time since the dock resizes.
|
||||
*/
|
||||
_clampPortableWindow(
|
||||
entry,
|
||||
@@ -224,7 +190,6 @@ export class PanelPositionControls {
|
||||
return { left: x, top: y };
|
||||
}
|
||||
|
||||
/** A window's size and how far down its header reaches. */
|
||||
_windowGeometry({ panel, handle }, rect = panel.getBoundingClientRect()) {
|
||||
const handleRect = handle?.getBoundingClientRect();
|
||||
const header = handleRect ? handleRect.bottom - rect.top : 0;
|
||||
@@ -269,9 +234,8 @@ export class PanelPositionControls {
|
||||
this._dragInitialized = true;
|
||||
const cctvPanel = document.getElementById('cctv-panel');
|
||||
const streetLevelPanel = document.getElementById('street-level-panel');
|
||||
// A `portable` spec lifts out of its rail on a header drag, resizes from
|
||||
// every edge and remembers its window; the others only reposition in
|
||||
// place. Another panel opts in by adding a spec here.
|
||||
// A `portable` panel lifts out of its rail on a header drag, resizes from
|
||||
// every edge and remembers its window; the others only reposition.
|
||||
const dragSpecs = [
|
||||
{
|
||||
id: 'pp-toggles',
|
||||
@@ -316,8 +280,8 @@ export class PanelPositionControls {
|
||||
}
|
||||
// Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its
|
||||
// full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and
|
||||
// the panel could still hang off the bottom (audit U2). Re-clamp on every size change. A floating
|
||||
// window grows the same way when its content does (an image opens, a collapsed strip expands).
|
||||
// the panel could still hang off the bottom (audit U2). Re-clamp on every size change. Floating
|
||||
// windows grow the same way when their content does.
|
||||
const observed = [
|
||||
this._ppToggles,
|
||||
...[...this._portablePanels.values()].map(({ panel }) => panel),
|
||||
@@ -403,14 +367,12 @@ export class PanelPositionControls {
|
||||
const pos = JSON.parse(raw);
|
||||
if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number')
|
||||
return;
|
||||
// A floating record only means something to a panel that can float; a
|
||||
// portable panel in turn only ever stores floating records.
|
||||
// Only portable panels store, and accept, floating records.
|
||||
const portable = this._portablePanels.get(panelId);
|
||||
if (Boolean(pos.floating) !== Boolean(portable)) return;
|
||||
if (portable) {
|
||||
panelEl.classList.add('panel-floating', 'panel-draggable');
|
||||
// A restored window must stack above the docked rail panels too, not
|
||||
// only one that was lifted or clicked this session.
|
||||
// A restored window must stack above the docked rail panels too.
|
||||
this._promotePanelZ(panelEl);
|
||||
if (Number.isFinite(pos.width)) {
|
||||
panelEl.style.width = `${fitWindowExtent(
|
||||
@@ -504,7 +466,7 @@ export class PanelPositionControls {
|
||||
if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return;
|
||||
localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1');
|
||||
} catch {
|
||||
// storage unavailable: still worth saying once this session
|
||||
// storage unavailable: still show it once this session
|
||||
if (this._floatHintShown) return;
|
||||
this._floatHintShown = true;
|
||||
}
|
||||
@@ -512,9 +474,8 @@ export class PanelPositionControls {
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch a portable panel from rail flow to a fixed window at the place it
|
||||
* currently occupies. Width is frozen so a `width: 100%` rail panel keeps
|
||||
* its size once fixed-positioned; height stays natural until a resize.
|
||||
* Turn a rail panel into a fixed window in place. Width is frozen so a
|
||||
* `width: 100%` panel keeps its size; height stays natural until a resize.
|
||||
*/
|
||||
_liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) {
|
||||
if (panelEl.classList.contains('panel-floating')) return false;
|
||||
@@ -522,7 +483,6 @@ export class PanelPositionControls {
|
||||
if (!panelEl.classList.contains('collapsed')) {
|
||||
panelEl.style.width = `${Math.round(rect.width)}px`;
|
||||
}
|
||||
// Rail bookkeeping no longer applies to a window the rail does not allocate.
|
||||
panelEl.style.removeProperty('--right-panel-allocated-height');
|
||||
panelEl.removeAttribute('aria-hidden');
|
||||
panelEl.classList.add('panel-floating', 'panel-draggable');
|
||||
@@ -619,13 +579,9 @@ export class PanelPositionControls {
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow one pointer gesture on the window; every panel gesture runs on
|
||||
* this. With a `threshold`, a press only becomes a gesture once the pointer
|
||||
* has travelled that far: `onStart` runs then, and `onMove` (given the
|
||||
* travel so far) only after it. `onEnd` (pointerup) and `onCancel`
|
||||
* (pointercancel, `onEnd` unless given) learn whether it started. A new
|
||||
* press, a snap-back or destroy() stops it through `_cancelDrag`, which
|
||||
* calls neither.
|
||||
* Track one pointer gesture. With a `threshold`, `onStart` and `onMove` wait
|
||||
* until the pointer has travelled that far; `onEnd`/`onCancel` get whether
|
||||
* it started. Stopping through `_cancelDrag` calls neither.
|
||||
*/
|
||||
_trackPointerGesture(
|
||||
event,
|
||||
@@ -665,18 +621,7 @@ export class PanelPositionControls {
|
||||
window.addEventListener('pointercancel', cancel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Portable drag: a press on the header only becomes a drag after a few
|
||||
* pixels of travel, at which point the panel lifts out of its rail. The
|
||||
* collapse button still toggles on a plain click, and the click that ends
|
||||
* a real drag is swallowed so it cannot toggle by accident. Double-clicking
|
||||
* the header snaps the panel back.
|
||||
*/
|
||||
/**
|
||||
* Put a floating portable panel back in its rail at its default size.
|
||||
* @param {string} panelId
|
||||
* @returns {boolean} Whether the panel was floating.
|
||||
*/
|
||||
/** @returns {boolean} Whether the panel was floating and is now docked. */
|
||||
dockPanel(panelId) {
|
||||
const portable = this._portablePanels.get(panelId);
|
||||
if (!portable?.panel.classList.contains('panel-floating')) return false;
|
||||
@@ -684,18 +629,17 @@ export class PanelPositionControls {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A panel was collapsed or expanded. A portable panel that opted into
|
||||
* `dockOnCollapse` returns to its rail when collapsed while floating.
|
||||
* @param {string} panelId
|
||||
* @param {boolean} collapsed
|
||||
*/
|
||||
/** A `dockOnCollapse` panel returns to its rail when collapsed while floating. */
|
||||
onPanelCollapsed(panelId, collapsed) {
|
||||
if (!collapsed || !this._portablePanels.get(panelId)?.dockOnCollapse)
|
||||
return;
|
||||
this.dockPanel(panelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The collapse button still toggles on a plain click; the click that ends a
|
||||
* real drag is swallowed so it cannot toggle by accident.
|
||||
*/
|
||||
_makePortablePanelDraggable(panelId, panelEl, handleEl) {
|
||||
let swallowNextClick = false;
|
||||
this.listen(
|
||||
@@ -790,11 +734,7 @@ export class PanelPositionControls {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser took a gesture back (pointercancel): leave no trace and save
|
||||
* nothing. A panel the gesture lifted returns to its rail; a window that
|
||||
* was already floating returns to where it was.
|
||||
*/
|
||||
/** On pointercancel, restore the panel as the gesture found it; save nothing. */
|
||||
_revertPanelGesture(panelId, panelEl, lifted, before) {
|
||||
if (lifted) {
|
||||
this._resetPanelPosition(panelId);
|
||||
@@ -804,11 +744,7 @@ export class PanelPositionControls {
|
||||
this._onPanelResized?.(panelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a resize handle for every edge and corner. Seven are invisible
|
||||
* strips; the bottom-right corner shows a grip. Resizing a docked panel
|
||||
* lifts it out first, so the handles work in both states.
|
||||
*/
|
||||
/** Resizing a docked panel lifts it out first, so handles work in both states. */
|
||||
_makePanelResizable(panelId, panelEl, min = {}) {
|
||||
const limits = { width: 300, height: 160, ...min };
|
||||
for (const dir of RESIZE_DIRECTIONS) {
|
||||
@@ -832,8 +768,8 @@ export class PanelPositionControls {
|
||||
if (event.button !== 0 || !isPrecisePointer(event)) return;
|
||||
event.preventDefault();
|
||||
this._cancelDrag?.();
|
||||
// Set once the pointer has travelled: a plain click on an edge (or on
|
||||
// the globe just beside a docked panel) must not lift it out.
|
||||
// Set once the pointer has travelled: a plain click on an edge must not
|
||||
// lift the panel out.
|
||||
let startBox = null;
|
||||
let lifted = false;
|
||||
let before = null;
|
||||
@@ -874,7 +810,7 @@ export class PanelPositionControls {
|
||||
onStop: () => panelEl.classList.remove('panel-resizing'),
|
||||
onEnd: (resized) => {
|
||||
if (!resized) return;
|
||||
// Re-clamping skipped the live gesture; a north edge may have carried
|
||||
// Re-clamping skipped the live gesture; a north edge may have moved
|
||||
// the header under the command dock.
|
||||
const entry = this._portablePanels.get(panelId);
|
||||
if (entry) this._reclampPortablePanel(entry);
|
||||
|
||||
@@ -357,10 +357,8 @@ test('right rail aligns to the current left rail and excludes hidden obstacles',
|
||||
assert.equal(f.stack.dataset.safeTop, '200.0');
|
||||
});
|
||||
|
||||
// The left pass commits its top and requests this pass in the same frame, but
|
||||
// `top` then animates for 150 ms and nothing re-runs the right pass when it
|
||||
// lands. CI docked the right rail at 234 px (26vh) while the left settled at
|
||||
// 266.4 px (29.6vh). Mid-flight the right rail must read the destination.
|
||||
// The left rail's `top` animates after the right pass runs, and nothing
|
||||
// re-runs it when the transition lands: the right rail must read the end value.
|
||||
function animatingLeftRail({ keyframes = true } = {}) {
|
||||
const f = fixture('right');
|
||||
const left = f.options.leftStack;
|
||||
|
||||
+3
-21
@@ -1,11 +1,6 @@
|
||||
/**
|
||||
* Pure geometry for resizing a floating panel from any edge or corner. The
|
||||
* DOM wiring lives in PanelPositionControls; this module only decides boxes.
|
||||
*/
|
||||
// Pure resize geometry for floating panels; PanelPositionControls wires the DOM.
|
||||
|
||||
/** Viewport margin a resized panel keeps clear on every side. */
|
||||
const EDGE_MARGIN_PX = 6;
|
||||
/** Resize directions: compass edges first, then corners. */
|
||||
export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se'];
|
||||
|
||||
function clamp(value, min, max) {
|
||||
@@ -13,21 +8,8 @@ function clamp(value, min, max) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out the box a resize gesture produces. Edges named in `dir` move with
|
||||
* the pointer; the opposite edge stays put, so growing from the left keeps
|
||||
* the right side pinned. Minimum size and the viewport margin win over the
|
||||
* pointer.
|
||||
* @param {{left: number, top: number, width: number, height: number}} box Box at gesture start.
|
||||
* @param {string} dir One of RESIZE_DIRECTIONS.
|
||||
* @param {number} dx Pointer travel since gesture start.
|
||||
* @param {number} dy Pointer travel since gesture start.
|
||||
* @param {object} [limits]
|
||||
* @param {number} limits.minWidth
|
||||
* @param {number} limits.minHeight
|
||||
* @param {number} [limits.viewportWidth=Infinity]
|
||||
* @param {number} [limits.viewportHeight=Infinity]
|
||||
* @param {number} [limits.margin=6]
|
||||
* @returns {{left: number, top: number, width: number, height: number}}
|
||||
* Edges named in `dir` follow the pointer (dx, dy since gesture start) and the
|
||||
* opposite edge stays put. Minimum size and the viewport margin win.
|
||||
*/
|
||||
export function resizeBox(
|
||||
box,
|
||||
|
||||
@@ -12,16 +12,9 @@ import { displayPanelScroller } from './displayPanelScroll.js';
|
||||
const pendingCollapseRetries = new WeakSet();
|
||||
|
||||
/**
|
||||
* Read where the left rail's top edge is going to settle, not where it is.
|
||||
* The left pass commits `--left-stack-safe-top` and requests this pass in the
|
||||
* same frame, but `top` then animates; nothing re-runs this pass when the
|
||||
* animation ends, because ResizeObserver ignores moves. Mid-flight, take the
|
||||
* running transition's end value (which also covers Cyber's fixed rail top),
|
||||
* falling back to the left pass's committed target.
|
||||
* @param {HTMLElement} leftStack Rail supplying the shared top baseline.
|
||||
* @param {number} viewportHeight Current viewport height in px.
|
||||
* @param {Function} getComputedStyle DOM style reader.
|
||||
* @returns {number} Settled top in px, or NaN when there is no left rail.
|
||||
* Where the left rail's top will settle, not where it is mid-transition:
|
||||
* nothing re-runs this pass when the `top` animation ends (ResizeObserver
|
||||
* ignores moves). Returns NaN when there is no left rail.
|
||||
*/
|
||||
function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
|
||||
const top = leftStack?.getBoundingClientRect().top;
|
||||
@@ -76,8 +69,7 @@ export function layoutRightPanelRail({
|
||||
// A collapse may schedule one follow-up, which only measures and allocates.
|
||||
const isCollapseRetry = pendingCollapseRetries.delete(stack);
|
||||
|
||||
// A panel lifted out of the rail (PanelPositionControls' portable mode)
|
||||
// is a fixed window the rail neither measures nor allocates.
|
||||
// A floating (lifted-out) panel is not laid out by the rail.
|
||||
const panels = [...stack.children].filter(
|
||||
(panel) =>
|
||||
panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden,
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
/** Arrow keys inside a radiogroup: the step to the next radio. */
|
||||
const RADIO_STEPS = Object.freeze({
|
||||
ArrowRight: 1,
|
||||
ArrowDown: 1,
|
||||
@@ -26,11 +25,8 @@ const setAttr = (node, key, value) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Own the Street Level panel: the provider chips, the imagery filters, the
|
||||
* legend and the embedded street-level viewer. The panel itself is
|
||||
* ordinary GEV chrome (collapse button, rail layout, persistence) driven by
|
||||
* the application shell; this class only fills the body and asks the shell
|
||||
* to open the panel when something worth seeing arrives.
|
||||
* Fills the Street Level panel body: provider chips, filters, legend and
|
||||
* viewer. The panel chrome itself belongs to the application shell.
|
||||
*/
|
||||
export class StreetLevelControls {
|
||||
constructor({ root, layer, actions }) {
|
||||
@@ -47,21 +43,14 @@ export class StreetLevelControls {
|
||||
// Until the user first touches the app, an off → on is the saved layer
|
||||
// state being restored, not a user switching the layer on.
|
||||
this._restoreWindow = true;
|
||||
/**
|
||||
* Whether the last request to switch the layer on came from the user
|
||||
* (or voice, or a tool) rather than a restore; null when the shell does
|
||||
* not report requests, and the restore window decides instead.
|
||||
*/
|
||||
// Whether the last switch-on was explicit (user, voice, tool) rather than
|
||||
// a restore; null when the shell does not report request origins.
|
||||
this._explicitEnable = null;
|
||||
this._unsubscribeEnableRequests = null;
|
||||
this._resizeQueued = false;
|
||||
this._wrapHome = null;
|
||||
this._expandReturnFocus = null;
|
||||
/**
|
||||
* The <body> children this class made inert while the expanded viewer is
|
||||
* a modal on screen; only these are released again.
|
||||
* @type {Set<Element>}
|
||||
*/
|
||||
// Only the <body> children this class made inert are released again.
|
||||
this._inerted = new Set();
|
||||
this._modalObserver = null;
|
||||
this._elements = this._collect();
|
||||
@@ -111,7 +100,6 @@ export class StreetLevelControls {
|
||||
this.layer.attachViewerHost?.(el.viewer);
|
||||
|
||||
this.listen(el.status, 'click', () => this._toggleEnabled());
|
||||
// One delegated listener; chips are re-synced in place on every render.
|
||||
this.listen(el.providerChips, 'click', (event) => {
|
||||
const button = event.target?.closest?.('.data-toggle-chip');
|
||||
if (!button || button.disabled) return;
|
||||
@@ -185,11 +173,7 @@ export class StreetLevelControls {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One radiogroup (ARIA radio pattern): a single tab stop, arrow keys move
|
||||
* the selection, Home and End jump to the ends. Selecting is the radio's
|
||||
* own click, so the keyboard takes the same path as the pointer.
|
||||
*/
|
||||
/** ARIA radio keys. Selecting clicks the radio, so keys and pointer share a path. */
|
||||
_bindRadioGroup(buttons) {
|
||||
buttons.forEach((button, index) => {
|
||||
this.listen(button, 'keydown', (event) => {
|
||||
@@ -208,9 +192,8 @@ export class StreetLevelControls {
|
||||
}
|
||||
|
||||
/**
|
||||
* Refit the viewer once, on the next frame, however many asked. A hidden
|
||||
* viewer measures 0×0 and would ask for tiles at z=NaN; the observer fires
|
||||
* again once it has a size.
|
||||
* Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would
|
||||
* ask for tiles at z=NaN; the observer fires again once it has a size.
|
||||
*/
|
||||
_requestResize() {
|
||||
if (this._resizeQueued) return;
|
||||
@@ -247,11 +230,7 @@ export class StreetLevelControls {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and provider changes go through the data manager as a user params
|
||||
* request, so saved state and share links record them; the layer applies
|
||||
* them through its own `setParams`.
|
||||
*/
|
||||
/** Through the data manager, so saved state and share links record it. */
|
||||
_setParams(params) {
|
||||
if (this.actions.setParams)
|
||||
this.actions.setParams(params, { origin: 'user' });
|
||||
@@ -259,9 +238,8 @@ export class StreetLevelControls {
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider chip is the layer's switch: lighting one turns the layer on
|
||||
* with that provider; darkening the last lit one turns the layer off (the
|
||||
* provider stays switched on, so the layer comes back with it).
|
||||
* Lighting a chip turns the layer on; darkening the last lit one turns the
|
||||
* layer off but keeps the provider on, so the layer comes back with it.
|
||||
*/
|
||||
async _toggleProvider(providerId) {
|
||||
const chip = this._view?.providers.find((entry) => entry.id === providerId);
|
||||
@@ -298,12 +276,10 @@ export class StreetLevelControls {
|
||||
if (this.layer.getUIState) this.render(this.layer.getUIState());
|
||||
}
|
||||
|
||||
/** The panel window was resized or docked again: refit the viewer. */
|
||||
onPanelResized() {
|
||||
this._requestResize();
|
||||
}
|
||||
|
||||
/** Ask the shell to open (or close) the rail panel. */
|
||||
setCollapsed(collapsed, options = {}) {
|
||||
this.actions.setPanelCollapsed?.(collapsed, options);
|
||||
if (!collapsed) this._requestResize();
|
||||
@@ -318,9 +294,8 @@ export class StreetLevelControls {
|
||||
}
|
||||
|
||||
/**
|
||||
* Grow the street-level viewer to most of the screen, or shrink it back.
|
||||
* A user's shrink (`dock`) also returns a floating panel to its rail at its
|
||||
* default size, so the viewer does not land in a window over the globe.
|
||||
* A user's shrink (`dock`) also returns a floating panel to its rail, so the
|
||||
* viewer does not land in a window over the globe.
|
||||
*/
|
||||
setViewerExpanded(expanded, { dock = false } = {}) {
|
||||
const wrap = this._elements.viewerWrap;
|
||||
@@ -384,10 +359,7 @@ export class StreetLevelControls {
|
||||
target?.focus?.({ preventScroll: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The expanded viewer holds Esc and Tab only while it is on screen: Clean
|
||||
* View, recording and the cockpit hide it with CSS.
|
||||
*/
|
||||
/** Clean View, recording and the cockpit hide the expanded viewer with CSS. */
|
||||
_isDialogShown() {
|
||||
if (!this.isViewerExpanded()) return false;
|
||||
const wrap = this._elements.viewerWrap;
|
||||
@@ -402,11 +374,9 @@ export class StreetLevelControls {
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the application modal-inert while the expanded viewer is on screen:
|
||||
* every other <body> child is `inert` (one already inert is left alone), so
|
||||
* no control around the viewer takes a click or focus. A viewer that CSS
|
||||
* hides (Clean View, recording, cockpit) releases them, since a hidden
|
||||
* modal must not leave the app dead. Returns whether the dialog is shown.
|
||||
* Make every other <body> child inert while the expanded viewer is shown.
|
||||
* A CSS-hidden viewer releases them so a hidden modal never leaves the app
|
||||
* dead. Returns whether the dialog is shown.
|
||||
*/
|
||||
_syncModal() {
|
||||
if (this.destroyed || !this._isDialogShown()) {
|
||||
@@ -422,17 +392,12 @@ export class StreetLevelControls {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Give the application back every node `_syncModal` made inert. */
|
||||
_releaseModal() {
|
||||
for (const node of this._inerted) node.inert = false;
|
||||
this._inerted.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* While expanded, re-check the modal whenever <body> changes class (the
|
||||
* modes that hide the viewer) or gains a child (which must be inert too).
|
||||
* Shrinking stops watching and releases the application.
|
||||
*/
|
||||
/** Re-check the modal when <body> changes class or gains a child. */
|
||||
_watchModal(on) {
|
||||
this._modalObserver?.disconnect();
|
||||
this._modalObserver = null;
|
||||
@@ -534,7 +499,6 @@ export class StreetLevelControls {
|
||||
setProp(el.errorText, 'textContent', view.error || '');
|
||||
}
|
||||
|
||||
/** Mark the checked radio of a group and make it the group's tab stop. */
|
||||
_renderRadios(buttons, isChecked) {
|
||||
for (const button of buttons) {
|
||||
const checked = isChecked(button);
|
||||
@@ -629,16 +593,14 @@ export class StreetLevelControls {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the panel at the moments a user would look for it. These opens are
|
||||
* never persisted: the stored collapse state stays the user's own choice.
|
||||
* Open the panel on a switch-on or when an image opens. Never persisted:
|
||||
* the stored collapse state stays the user's own choice.
|
||||
*/
|
||||
_reactToTransitions(view, state) {
|
||||
const enabled = view.enabled;
|
||||
if (enabled && this._wasEnabled === false) {
|
||||
// A restore that turns the saved layer back on must not reopen a panel
|
||||
// the user (or a share link) chose to keep collapsed. With the shell's
|
||||
// request origins the panel opens only for a user, voice or tool
|
||||
// switch-on; without them, the restore window stands in.
|
||||
// A restore must not reopen a panel the user or a share link kept
|
||||
// collapsed. Without request origins, the restore window decides.
|
||||
const preference = this.root.dataset?.collapsedPreference;
|
||||
const restoring =
|
||||
this._explicitEnable === null
|
||||
@@ -650,10 +612,8 @@ export class StreetLevelControls {
|
||||
this._explicitEnable = this._explicitEnable === null ? null : false;
|
||||
}
|
||||
this._wasEnabled = enabled;
|
||||
// Size changes reach the viewer through the ResizeObserver; resizing on
|
||||
// every render ran each frame while a panorama was dragged. Opening is the
|
||||
// one moment the element may not have reported a size yet, and opening
|
||||
// the panel asks for that one (coalesced) resize.
|
||||
// Other size changes reach the viewer through the ResizeObserver; opening
|
||||
// asks for one resize because the element may not have a size yet.
|
||||
const open = state.street.open === true;
|
||||
if (open && !this._wasStreetOpen)
|
||||
this.setCollapsed(false, { persist: false });
|
||||
|
||||
@@ -13,10 +13,7 @@ import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
/* ── A small DOM: just what the panel controls touch ───────────────────── */
|
||||
|
||||
/**
|
||||
* Every write the page would see as a mutation: an attribute set or removed,
|
||||
* or a reflected property assigned (even to the value it already has).
|
||||
*/
|
||||
/** Every write a page would see as a mutation, even one to the same value. */
|
||||
const mutations = { count: 0 };
|
||||
const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value'];
|
||||
/** The reflected properties a MutationObserver sees, as the record it gets. */
|
||||
@@ -27,10 +24,8 @@ const REFLECTED_RECORDS = {
|
||||
textContent: { type: 'childList' },
|
||||
};
|
||||
|
||||
/** The fake MutationObservers currently observing (see fakeMutationObserver). */
|
||||
const liveObservers = new Set();
|
||||
|
||||
/** Queue a mutation record for every observer whose options cover it. */
|
||||
function recordMutation(target, { type, attributeName = null }) {
|
||||
for (const observer of liveObservers)
|
||||
observer.consider(target, type, attributeName);
|
||||
@@ -116,10 +111,7 @@ class FakeNode {
|
||||
const siblings = this.parent.children;
|
||||
return siblings[siblings.indexOf(this) + 1] || null;
|
||||
}
|
||||
/**
|
||||
* As layout reports it: null when this node or an ancestor is hidden (no
|
||||
* box), or when it is not in the document; else its parent.
|
||||
*/
|
||||
/** Null when this node or an ancestor is hidden or disconnected. */
|
||||
get offsetParent() {
|
||||
if (!this.isConnected) return null;
|
||||
for (let node = this; node; node = node.parent)
|
||||
@@ -388,11 +380,7 @@ const memoryStorage = () => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The production path a panel click takes: controls → dataManager
|
||||
* .setLayerParams → layer.setParams → LayerStateCoordinator's durable state,
|
||||
* which is what share links and saved state encode.
|
||||
*/
|
||||
/** The production path: controls → dataManager → layer → durable layer state. */
|
||||
async function productionPanel(dom) {
|
||||
const provider = standInProvider();
|
||||
const layer = createStreetLevelLayer({ providers: [provider.def] });
|
||||
@@ -592,7 +580,7 @@ test('lighting a dark chip switches the provider on as a user params request, th
|
||||
assert.deepEqual(calls.setEnabled, [true]);
|
||||
}));
|
||||
|
||||
test('the legend rebuilds when a swatch changes, even at the same count (review #9)', () =>
|
||||
test('the legend rebuilds when a swatch changes, even at the same count', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer } = stubPanel(dom, uiState());
|
||||
const legend = dom.root.querySelector('#sl-legend');
|
||||
@@ -610,7 +598,7 @@ test('the legend rebuilds when a swatch changes, even at the same count (review
|
||||
assert.equal(swatch(), '#a66bff');
|
||||
}));
|
||||
|
||||
test('the viewer is resized once when it opens, not on every render (review #8, gekh P3)', () =>
|
||||
test('the viewer is resized once when it opens, not on every render', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer } = stubPanel(dom, uiState());
|
||||
await settle();
|
||||
@@ -714,7 +702,7 @@ test('an image closed while expanded does not return focus into the hidden viewe
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
/* ── gekh review on #768 ───────────────────────────────────────────────── */
|
||||
/* ── Restores, the modal viewer and keyboard rules ─────────────────────── */
|
||||
|
||||
test('a restored layer does not reopen a panel the user collapsed, and automatic opens are never stored (P2-1)', () =>
|
||||
withDom(async (dom) => {
|
||||
@@ -811,10 +799,8 @@ test('a hidden expanded viewer (Clean View, recording, cockpit) holds neither Es
|
||||
}));
|
||||
|
||||
/**
|
||||
* A MutationObserver stand-in. Like the real one it queues only the records
|
||||
* its `observe` options cover (childList, attributes / attributeFilter,
|
||||
* subtree) and delivers them in a microtask, only when there are some;
|
||||
* `fire()` delivers what is queued at once.
|
||||
* A MutationObserver stand-in: queues only the records its options cover and
|
||||
* delivers them in a microtask; `fire()` delivers at once.
|
||||
*/
|
||||
function fakeMutationObserver() {
|
||||
const observers = [];
|
||||
@@ -888,7 +874,7 @@ const inertOutside = (dom) =>
|
||||
.filter((node) => node.id !== 'sl-viewer-wrap')
|
||||
.map((node) => [node.id, node.inert === true]);
|
||||
|
||||
test('the expanded viewer makes the rest of the app inert and gives it back on shrink (IC8 P2)', () =>
|
||||
test('the expanded viewer makes the rest of the app inert and gives it back on shrink', () =>
|
||||
withObserver(() =>
|
||||
withDom(async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
@@ -919,7 +905,7 @@ test('the expanded viewer makes the rest of the app inert and gives it back on s
|
||||
}),
|
||||
));
|
||||
|
||||
test('a CSS-hidden expanded viewer releases the app; showing it again, and new children, go inert (IC8 P2)', () =>
|
||||
test('a CSS-hidden expanded viewer releases the app; showing it again, and new children, go inert', () =>
|
||||
withObserver((observer) =>
|
||||
withDom(async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
@@ -954,7 +940,7 @@ test('a CSS-hidden expanded viewer releases the app; showing it again, and new c
|
||||
}),
|
||||
));
|
||||
|
||||
test('an image closed while expanded releases the app (IC8 P2)', () =>
|
||||
test('an image closed while expanded releases the app', () =>
|
||||
withObserver(() =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
|
||||
/**
|
||||
* Turn the Street Level layer's UI state into the strings and flags the panel
|
||||
* renders. Pure: no DOM, no layer calls, so every wording decision is testable.
|
||||
*/
|
||||
// Street Level UI state to panel strings and flags. Pure, so it is testable.
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Stops of the SINCE slider, oldest window on the left: position 0 shows
|
||||
* every capture, the right end only the last month. Stored as relative days
|
||||
* so a share link keeps its meaning over time.
|
||||
*/
|
||||
/** SINCE slider stops, in relative days so a share link keeps its meaning. */
|
||||
export const SINCE_STOPS = Object.freeze([
|
||||
Object.freeze({ days: 0, label: 'ANY DATE' }),
|
||||
Object.freeze({ days: 3652, label: 'LAST 10 YEARS' }),
|
||||
@@ -24,7 +17,7 @@ export const SINCE_STOPS = Object.freeze([
|
||||
Object.freeze({ days: 30, label: 'LAST MONTH' }),
|
||||
]);
|
||||
|
||||
/** Slider position for a day count: the exact stop, else the nearest one. */
|
||||
/** The exact stop for a day count, else the nearest one. */
|
||||
export function sinceStopIndex(days) {
|
||||
const value = Number(days) || 0;
|
||||
if (value <= 0) return 0;
|
||||
@@ -38,7 +31,6 @@ export function sinceStopIndex(days) {
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Readout beside the slider: the window, plus the cut-off date it means today. */
|
||||
function presentSince(days, now) {
|
||||
const value = Number(days) || 0;
|
||||
const index = sinceStopIndex(value);
|
||||
@@ -61,7 +53,6 @@ function formatDate(ms) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The header pill doubles as the layer's on/off switch. */
|
||||
function presentStatus(state) {
|
||||
const pressed = state.enabled === true;
|
||||
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
|
||||
@@ -76,11 +67,7 @@ function presentStatus(state) {
|
||||
: { text: 'OFF', tone: '', pressed, title };
|
||||
}
|
||||
|
||||
/**
|
||||
* One chip per registered provider; a keyless provider reads as an error
|
||||
* chip. A chip is lit only while the layer is on and that provider is
|
||||
* switched on, so with a single provider the chip is the layer's switch.
|
||||
*/
|
||||
/** A chip is lit only while the layer and its provider are both on. */
|
||||
function presentProviders(state) {
|
||||
const enabled = state.enabled === true;
|
||||
return (state.providers || []).map((provider) => {
|
||||
@@ -148,18 +135,13 @@ function presentMeta(state) {
|
||||
return state.coverage.hint || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} state Snapshot from the layer's `getUIState()`.
|
||||
* @param {{now?: number}} [options] Clock for the SINCE readout (tests pin it).
|
||||
* @returns {object} Everything the panel needs, already worded.
|
||||
*/
|
||||
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
|
||||
export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
||||
const enabled = state.enabled === true;
|
||||
const keyRequired = state.keyRequired === true;
|
||||
const filter = state.filter || { pano: 'all', sinceDays: 0 };
|
||||
// KEY REQUIRED already says it, and the chip's tooltip says how to fix it;
|
||||
// the error under it would only repeat a raw code (`no_key`). A rejected
|
||||
// key keeps its line: that message names the fix.
|
||||
// A missing key is already shown by the status and chip tooltip; only a
|
||||
// rejected key keeps its error line, since that message names the fix.
|
||||
const keyMissing = keyRequired && state.keyRejected !== true;
|
||||
return {
|
||||
enabled,
|
||||
@@ -175,7 +157,6 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
||||
legend: state.legend || [],
|
||||
viewer: presentViewer(state),
|
||||
meta: presentMeta(state),
|
||||
/** The panel opens itself when an image opens (a native panel stays put otherwise). */
|
||||
wantsOpen: state.street.open === true,
|
||||
};
|
||||
}
|
||||
@@ -65,7 +65,7 @@ test('a key-gated layer disables every control and flags KEY REQUIRED', () => {
|
||||
assert.equal(view.status.tone, 'warn');
|
||||
});
|
||||
|
||||
test('KEY REQUIRED shows no raw error code under the pill (review P3)', () => {
|
||||
test('KEY REQUIRED shows no raw error code under the pill', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
@@ -80,7 +80,7 @@ test('KEY REQUIRED shows no raw error code under the pill (review P3)', () => {
|
||||
assert.match(view.providers[0].title, /^Mapillary: Needs /);
|
||||
});
|
||||
|
||||
test('a key Mapillary rejected reads KEY REJECTED and names the fix (review IC8 P2)', () => {
|
||||
test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => {
|
||||
const error =
|
||||
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
|
||||
const view = presentStreetLevelPanel(
|
||||
|
||||
@@ -104,10 +104,8 @@
|
||||
}
|
||||
|
||||
/* ── Portable rail panels ────────────────────
|
||||
A header drag lifts a portable panel (PanelPositionControls) out of its
|
||||
rail into a fixed window that remembers its place and size. The rail's
|
||||
own placement rules in layers.css are guarded with :not(.panel-floating),
|
||||
so these only have to beat a panel's base placement (one id). */
|
||||
Rail placement in layers.css is guarded with :not(.panel-floating), so
|
||||
these rules only have to beat a panel's base placement (one id). */
|
||||
.panel-portable .panel-header {
|
||||
cursor: grab;
|
||||
}
|
||||
@@ -117,16 +115,14 @@
|
||||
#right-context-rail > .panel-floating {
|
||||
position: fixed;
|
||||
inset: auto;
|
||||
/* Above every docked rail panel (#pp-toggles is 110). PanelPositionControls
|
||||
promotes floating windows from this floor (PANEL_Z_FLOATING_FLOOR). */
|
||||
/* Above every docked rail panel; keep in step with PANEL_Z_FLOATING_FLOOR. */
|
||||
z-index: 111;
|
||||
max-height: calc(100vh - 12px);
|
||||
margin: 0;
|
||||
flex: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* A collapsed window keeps its stored height for later but shows only its
|
||||
header strip, so the resize handles have nothing to attach to. */
|
||||
/* A collapsed window shows only its header strip; its inline height stays. */
|
||||
#right-context-rail > .panel-floating.collapsed {
|
||||
max-height: max-content;
|
||||
}
|
||||
@@ -1110,7 +1106,7 @@ body.ui-clean-view #cctv-panel,
|
||||
body.ui-clean-view #radio-panel,
|
||||
body.ui-clean-view #global-context-panel,
|
||||
body.ui-clean-view #scene-panel,
|
||||
/* The expanded Street Level viewer lives on <body>, outside every panel. */
|
||||
/* The expanded viewer lives on <body>, outside every panel. */
|
||||
body.ui-clean-view .sl-viewer-wrap-expanded {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
|
||||
@@ -16,7 +16,7 @@ body.recording-mode #radio-panel,
|
||||
body.recording-mode #global-context-panel,
|
||||
body.recording-mode #gev-voice-control,
|
||||
body.recording-mode #scene-panel,
|
||||
/* The expanded Street Level viewer lives on <body>, outside every panel. */
|
||||
/* The expanded viewer lives on <body>, outside every panel. */
|
||||
body.recording-mode .sl-viewer-wrap-expanded {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* ── Street Level panel ─────────────────────────────────────────
|
||||
Lives in the right context rail next to CCTV; the rail owns position and
|
||||
width (see layers.css). Everything here is panel-internal. The expanded
|
||||
viewer is re-parented to <body>, so it carries the tokens too. */
|
||||
The rail owns position and width (layers.css). The expanded viewer is
|
||||
re-parented to <body>, so it carries the tokens too. */
|
||||
#street-level-panel,
|
||||
.sl-viewer-wrap {
|
||||
--sl-green: #05cb63;
|
||||
@@ -111,8 +110,6 @@ button.sl-status:focus-visible {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The filters a missing key gates. The provider chips stay outside it, so
|
||||
their tooltip (how to add the key) is still reachable. */
|
||||
.sl-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -128,8 +125,7 @@ button.sl-status:focus-visible {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Everything but the viewer. Docked, the whole panel scrolls; in a resized
|
||||
window the viewer keeps its room and only this block scrolls. */
|
||||
/* In a resized window only this block scrolls; the viewer keeps its room. */
|
||||
.sl-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -151,8 +147,7 @@ button.sl-status:focus-visible {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* One colour per source: a swatch dot, and the whole chip in that colour
|
||||
while the source is on. `--chip-color` comes from the provider. */
|
||||
/* `--chip-color` comes from the provider. */
|
||||
.sl-chips .data-toggle-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -246,7 +241,6 @@ button.sl-status:focus-visible {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* SINCE: a stepped slider, oldest window left, newest right. */
|
||||
.sl-since {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
@@ -318,8 +312,7 @@ button.sl-status:focus-visible {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The image stage: the viewer and its loading veil, nothing on top of it,
|
||||
so the provider's own controls (sequence bar, zoom, bearing) stay clear. */
|
||||
/* Nothing sits over the image, so the provider's own controls stay clear. */
|
||||
.sl-viewer-stage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -362,7 +355,6 @@ button.sl-status:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
/* Our tools live in a bar above the image rather than over it. */
|
||||
.sl-viewer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -485,16 +477,15 @@ button.sl-status:focus-visible {
|
||||
}
|
||||
|
||||
/* ── Floating window ─────────────────────────────────────────────────────── */
|
||||
/* A window that was only dragged keeps its natural height; bound it to the
|
||||
viewport so nothing runs off-screen (the rail rule's max-height: 100% does
|
||||
not bind an auto-height parent). Id-qualified to outrank that rule. */
|
||||
/* A dragged window keeps its natural height, so bound it to the viewport:
|
||||
the rail's max-height: 100% does not bind an auto-height parent.
|
||||
Id-qualified to outrank that rule. */
|
||||
#right-context-rail > #street-level-panel.panel-floating > .street-level-panel-inner {
|
||||
max-height: calc(100vh - 12px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* A resized window (PanelPositionControls writes an inline height) hands the
|
||||
spare height to the viewer, so the image never needs scrolling to. */
|
||||
/* A resized window (inline height) gives its spare height to the viewer. */
|
||||
.panel-floating[style*='height'] > .street-level-panel-inner {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
@@ -532,9 +523,8 @@ button.sl-status:focus-visible {
|
||||
}
|
||||
|
||||
/* ── Expanded viewer ─────────────────────────────────────────────────────── */
|
||||
/* The panel inner's backdrop-filter makes it the containing block for fixed
|
||||
descendants, so the expanded viewer is re-parented to <body>. z-index sits
|
||||
above panels (≤139) and below the voice pill (150) and toasts (200). */
|
||||
/* Re-parented to <body>: the panel's backdrop-filter would contain a fixed
|
||||
child. z-index sits above panels (≤139), below the voice pill (150). */
|
||||
.sl-viewer-wrap-expanded {
|
||||
position: fixed;
|
||||
left: 4vw;
|
||||
@@ -604,10 +594,9 @@ button.sl-status:focus-visible {
|
||||
}
|
||||
|
||||
/* ── Phones ──────────────────────────────────────────────────────────────── */
|
||||
/* At this width the right rail is the band between mid-screen and the credit
|
||||
line (responsive.css: top 50vh + 8px, bottom 2vh + 7.5rem). Size the photo
|
||||
from that band, less the panel's header, tool bar and caption (~150px), so
|
||||
the whole image shows without scrolling; EXPAND still gives full screen. */
|
||||
/* The phone rail spans 50vh + 8px to 2vh + 7.5rem from the bottom
|
||||
(responsive.css). Size the photo to that band, less ~150px of panel chrome,
|
||||
so it shows without scrolling. */
|
||||
@media (max-width: 720px) {
|
||||
.sl-viewer {
|
||||
aspect-ratio: auto;
|
||||
|
||||
@@ -71,8 +71,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Street Level (Mapillary) Panel — moved into #right-context-rail at
|
||||
runtime next to CCTV (see PanelLayoutController). -->
|
||||
<!-- Street Level Panel (moved into #right-context-rail at runtime) -->
|
||||
<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="street-level-panel-inner" data-rail-scroller>
|
||||
@@ -116,8 +115,8 @@
|
||||
<div id="sl-provider-chips" class="sl-chips"></div>
|
||||
</div>
|
||||
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
|
||||
<!-- KEY REQUIRED gates the filters only: the provider chips above
|
||||
stay focusable so their tooltip can say how to add the key. -->
|
||||
<!-- A missing key gates the filters only: the chips above stay
|
||||
focusable so their tooltip can say how to add the key. -->
|
||||
<fieldset id="sl-controls" class="sl-controls">
|
||||
<div class="sl-filters" role="group" aria-label="Imagery filters">
|
||||
<span class="cctv-summary-label">IMAGERY</span>
|
||||
|
||||
Reference in new issue
Block a user