diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ba7f5111b..b77301f29 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -274,6 +274,7 @@ "src/ui/styles.js", "src/ui/shellLifecycle.test.mjs", "src/testSupport/readStylesheet.mjs", + "src/testSupport/streetLevelFakes.mjs", "src/splitFlap.js", "style.css", "src/ui/styles/overlays.css", diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 72aba7f60..7cad52139 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -200,6 +200,23 @@ function readPanel(page) { }); } +/** Wait until Street Level is on, drawn, settled and not key-gated. */ +function waitForCoverage(page, { timeout = 90_000 } = {}) { + return page.waitForFunction( + () => { + const dm = window.__godsEyeView.dataManager; + const u = dm.layers.get('street-level').module.getUIState(); + return ( + dm.isEnabled('street-level') && + u.coverage.count > 0 && + !u.coverage.loading && + !u.keyRequired + ); + }, + { timeout }, + ); +} + /** Without a key on the server the panel gates its controls and says why. */ async function assertKeylessGate(page) { await page.evaluate(() => @@ -360,15 +377,7 @@ async function main() { origin: 'user', }), ); - await page.waitForFunction( - () => { - const u = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState(); - return u.coverage.count > 0 && !u.coverage.loading; - }, - { timeout: 90_000 }, - ); + await waitForCoverage(page); const info = await panel(); assert.equal(info.controlsDisabled, false); // Cesium paints on-screen credits a frame or two after they register. @@ -410,18 +419,7 @@ async function main() { ); assert.equal((await ui()).coverage.count, 0); await page.click('#sl-status'); - await page.waitForFunction( - () => { - const dm = window.__godsEyeView.dataManager; - const u = dm.layers.get('street-level').module.getUIState(); - return ( - dm.isEnabled('street-level') && - u.coverage.count > 0 && - !u.coverage.loading - ); - }, - { timeout: 90_000 }, - ); + await waitForCoverage(page); assert.equal( await page.$eval('#sl-status', (node) => node.getAttribute('aria-pressed'), @@ -458,18 +456,7 @@ async function main() { 'lighting the chip turns the layer back on with coverage', async () => { await page.click(chip); - await page.waitForFunction( - () => { - const dm = window.__godsEyeView.dataManager; - const u = dm.layers.get('street-level').module.getUIState(); - return ( - dm.isEnabled('street-level') && - u.coverage.count > 0 && - !u.coverage.loading - ); - }, - { timeout: 90_000 }, - ); + await waitForCoverage(page); assert.equal( await page.$eval(chip, (node) => node.getAttribute('aria-pressed')), 'true', @@ -614,17 +601,7 @@ async function main() { ); fixture.tiles = 'ok'; await page.click('#sl-status'); - await page.waitForFunction( - () => { - const u = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState(); - return ( - u.coverage.count > 0 && !u.coverage.loading && !u.keyRequired - ); - }, - { timeout: 90_000 }, - ); + await waitForCoverage(page); await statusText('ON'); }, ); @@ -806,16 +783,7 @@ async function main() { .module.getUIState().surface === 'draped', { timeout: 10_000 }, ); - const coverageLoaded = () => - page.waitForFunction( - () => { - const u = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState(); - return u.coverage.count > 0 && !u.coverage.loading; - }, - { timeout: 60_000 }, - ); + const coverageLoaded = () => waitForCoverage(page, { timeout: 60_000 }); await coverageLoaded(); assert.equal((await surface()).surface, 'draped', 'draped on Esri'); const photoreal = await page.evaluate(() => diff --git a/server/providers/mapillary/tiles.js b/server/providers/mapillary/tiles.js index 4d4e92faf..47d273a4e 100644 --- a/server/providers/mapillary/tiles.js +++ b/server/providers/mapillary/tiles.js @@ -20,6 +20,11 @@ import { mapillaryToken, } from './constants.js'; +/** Log a background failure without its stack (never the token: none is in scope). */ +function warn(what, error) { + console.warn(`[Mapillary Proxy] ${what}:`, error?.message || error); +} + /** Thrown for a request this proxy refuses before contacting Mapillary. */ export class TileRequestError extends Error { constructor(message, status = 400) { @@ -224,10 +229,7 @@ function writeDisk(address, bytes, at = null) { }) .catch(async (error) => { await fsp.rm(tmp, { force: true }).catch(() => {}); - console.warn( - '[Mapillary Proxy] tile cache write failed:', - error?.message || error, - ); + warn('tile cache write failed', error); }) .finally(() => { if (newest()) _diskWrites.delete(file); @@ -248,10 +250,7 @@ function scheduleSweep(root) { .catch((error) => { if (_sweepWarned) return; _sweepWarned = true; - console.warn( - '[Mapillary Proxy] tile cache sweep failed:', - error?.message || error, - ); + warn('tile cache sweep failed', error); }) .finally(() => { if (_sweeping === tracked) _sweeping = null; @@ -417,10 +416,7 @@ function trim(address, bytes) { try { return stripTileLayers(bytes, address.dropLayers); } catch (error) { - console.warn( - '[Mapillary Proxy] tile trim failed, serving raw:', - error?.message || error, - ); + warn('tile trim failed, serving raw', error); return bytes; } } diff --git a/src/layers/streetLevel/glyphs.js b/src/layers/streetLevel/glyphs.js index 22416e4f5..823d2f7e8 100644 --- a/src/layers/streetLevel/glyphs.js +++ b/src/layers/streetLevel/glyphs.js @@ -6,81 +6,76 @@ const _cache = new Map(); -function canvas(size) { +/** A square canvas glyph drawn once per key, centred at (c, c). */ +function cachedGlyph(key, size, draw) { + if (_cache.has(key)) return _cache.get(key); const element = document.createElement('canvas'); element.width = size; element.height = size; + draw(element.getContext('2d'), size / 2); + _cache.set(key, element); return element; } +/** A filled wedge from the centre, pointing "up", `halfAngle` radians wide. */ +function wedge(ctx, c, radius, halfAngle, color, alpha) { + ctx.beginPath(); + ctx.moveTo(c, c); + ctx.arc(c, c, radius, -Math.PI / 2 - halfAngle, -Math.PI / 2 + halfAngle); + ctx.closePath(); + ctx.fillStyle = color; + ctx.globalAlpha = alpha; + ctx.fill(); + 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); + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.globalAlpha = alpha; + ctx.stroke(); + 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); + ctx.fillStyle = color; + ctx.fill(); + ctx.lineWidth = width; + ctx.strokeStyle = outline; + ctx.stroke(); +} + /** Cone glyph pointing "up"; rotate the billboard by the compass angle. */ export function imageConeGlyph({ size = 32, color = '#e8eaed', pano = false, } = {}) { - const key = `cone:${size}:${color}:${pano}`; - if (_cache.has(key)) return _cache.get(key); - const element = canvas(size); - const ctx = element.getContext('2d'); - const c = size / 2; - if (!pano) { - ctx.beginPath(); - ctx.moveTo(c, c); - ctx.arc(c, c, size * 0.46, -Math.PI / 2 - 0.62, -Math.PI / 2 + 0.62); - ctx.closePath(); - ctx.fillStyle = color; - ctx.globalAlpha = 0.42; - ctx.fill(); - ctx.globalAlpha = 1; - } else { - ctx.beginPath(); - ctx.arc(c, c, size * 0.4, 0, Math.PI * 2); - ctx.strokeStyle = color; - ctx.globalAlpha = 0.5; - ctx.lineWidth = Math.max(1.5, size * 0.09); - ctx.stroke(); - ctx.globalAlpha = 1; - } - ctx.beginPath(); - ctx.arc(c, c, size * 0.16, 0, Math.PI * 2); - ctx.fillStyle = color; - ctx.fill(); - ctx.lineWidth = Math.max(1, size * 0.05); - ctx.strokeStyle = 'rgba(10,10,15,0.85)'; - ctx.stroke(); - _cache.set(key, element); - return element; + return cachedGlyph(`cone:${size}:${color}:${pano}`, size, (ctx, c) => { + if (pano) ring(ctx, c, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5); + else wedge(ctx, c, size * 0.46, 0.62, color, 0.42); + dot( + ctx, + c, + size * 0.16, + color, + 'rgba(10,10,15,0.85)', + Math.max(1, size * 0.05), + ); + }); } /** Pulsing-looking marker for the image the viewer currently shows. */ export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) { - const key = `pos:${size}:${color}`; - if (_cache.has(key)) return _cache.get(key); - const element = canvas(size); - const ctx = element.getContext('2d'); - const c = size / 2; - ctx.beginPath(); - ctx.moveTo(c, c); - ctx.arc(c, c, size * 0.48, -Math.PI / 2 - 0.55, -Math.PI / 2 + 0.55); - ctx.closePath(); - ctx.fillStyle = color; - ctx.globalAlpha = 0.55; - ctx.fill(); - ctx.globalAlpha = 1; - ctx.beginPath(); - ctx.arc(c, c, size * 0.2, 0, Math.PI * 2); - ctx.fillStyle = color; - ctx.fill(); - ctx.lineWidth = 2; - ctx.strokeStyle = '#0a0a0f'; - ctx.stroke(); - ctx.beginPath(); - ctx.arc(c, c, size * 0.3, 0, Math.PI * 2); - ctx.strokeStyle = color; - ctx.lineWidth = 1.5; - ctx.globalAlpha = 0.8; - ctx.stroke(); - _cache.set(key, element); - return element; + return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => { + wedge(ctx, c, size * 0.48, 0.55, color, 0.55); + dot(ctx, c, size * 0.2, color, '#0a0a0f', 2); + ring(ctx, c, size * 0.3, color, 1.5, 0.8); + }); } diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 06ef1f972..63c1852ff 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -3,64 +3,7 @@ import test from 'node:test'; import * as Cesium from 'cesium'; import { createStreetLevelLayer } from './index.js'; import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js'; - -/** - * The smallest provider the core accepts. `stats` is what coverageStats() - * answers; `calls` counts what the core asked of the provider and its viewer. - */ -function fakeProvider({ - id = 'mapillary', - pickPrefix = 'mly:', - html = MAPILLARY_CREDIT_HTML, - nearestImage = async () => null, -} = {}) { - const calls = { activate: 0, deactivate: 0, mount: 0, open: [], unmount: 0 }; - const stats = { - count: 0, - zoom: null, - kind: null, - loading: false, - hint: '', - error: null, - keyRequired: false, - }; - let context = null; - return { - calls, - stats, - context: () => context, - id, - name: 'Mapillary', - label: 'MAPILLARY', - requiresKeyId: null, - pickPrefix, - colors: { coverage: '#05cb63' }, - credit: { html }, - create: (providerContext) => { - context = providerContext; - return { - status: async () => ({ configured: true }), - init() {}, - activate: () => calls.activate++, - deactivate: () => calls.deactivate++, - destroy() {}, - refreshCoverage() {}, - setFilter() {}, - coverageStats: () => ({ ...stats }), - handlePick: () => false, - nearestImage, - viewer: { - mount: async () => calls.mount++, - open: async (imageId) => calls.open.push(imageId), - close() {}, - unmount: () => calls.unmount++, - resize() {}, - onPose: () => () => {}, - }, - }; - }, - }; -} +import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs'; /** * A stand-in Cesium viewer the layer can be enabled on: a canvas for the diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs index 25099d9b5..6ed700e5b 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test'; import * as Cesium from 'cesium'; import { SWAP_MAX_WAIT_MS, createCoverage } from './coverage.js'; import { encodeCoverageTile } from './coverageFixture.mjs'; +import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs'; import { COLORS, COVERAGE_MAX_SEQUENCES, @@ -641,46 +642,19 @@ test('the draped lines go after the wait even if the cast lines never get ready' coverage.clear(); }); -/** - * A camera `height` m above WGS84 looking along `heading` with `pitch` (deg), - * a 60°×40° view on a 100×100 canvas, whose screen rays really meet the - * ellipsoid they are given (as Cesium's `pickEllipsoid` does). - */ +/** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */ function tiltedCamera({ lon, lat, height, heading, pitch }) { - const position = Cesium.Cartesian3.fromDegrees(lon, lat, height); - const frameAt = Cesium.Transforms.eastNorthUpToFixedFrame(position); - function ray(point) { - const h = (heading + (point.x / 100 - 0.5) * 60) * RAD; - const p = (pitch + (0.5 - point.y / 100) * 40) * RAD; - const local = new Cesium.Cartesian3( - Math.sin(h) * Math.cos(p), - Math.cos(h) * Math.cos(p), - Math.sin(p), - ); - const direction = Cesium.Matrix4.multiplyByPointAsVector( - frameAt, - local, - new Cesium.Cartesian3(), - ); - return new Cesium.Ray( - position, - Cesium.Cartesian3.normalize(direction, direction), - ); - } - return { - positionWC: position, - positionCartographic: { - longitude: lon * RAD, - latitude: lat * RAD, - height, - }, - pickEllipsoid(point, ellipsoid) { - const r = ray(point); - const hit = Cesium.IntersectionTests.rayEllipsoid(r, ellipsoid); - return hit ? Cesium.Ray.getPoint(r, hit.start) : undefined; - }, - computeViewRectangle: () => undefined, - }; + return rayCamera({ + lon, + lat, + altitude: height, + heading, + pitch, + width: 100, + height: 100, + fovX: 60, + fovY: 40, + }); } /** diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index 550768db8..1c3273ab5 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -2,22 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { composeUIState, summarizeCoverage } from './uiState.js'; import { COLORS } from './policy.js'; +import { providerSnapshot } from '../../testSupport/streetLevelFakes.mjs'; -const provider = (overrides = {}) => ({ - id: 'mapillary', - name: 'Mapillary', - label: 'MAPILLARY', - on: true, - configured: true, - keyRequired: false, - requiresKeyId: 'mapillary', - loading: false, - count: 10, - hint: '', - error: null, - color: '#05cb63', - ...overrides, -}); +const provider = (overrides = {}) => + providerSnapshot({ count: 10, ...overrides }); const base = { enabled: true, filter: { pano: 'all', sinceDays: 0 }, diff --git a/src/layers/streetLevel/view.test.mjs b/src/layers/streetLevel/view.test.mjs index d39bdc7d1..f2e3fe038 100644 --- a/src/layers/streetLevel/view.test.mjs +++ b/src/layers/streetLevel/view.test.mjs @@ -7,6 +7,7 @@ import { viewFocus, visibleBbox, } from './view.js'; +import { rayCamera } from '../../testSupport/streetLevelFakes.mjs'; const RAD = Math.PI / 180; /** A viewer 300 m above a street 1,600 m up (Denver-like). */ @@ -82,50 +83,21 @@ test('an ordinary view keeps west < east', () => { ); }); -/** - * A pinhole camera `agl` metres above ground `ground` m up, looking north at - * `pitch` degrees, whose rays meet whatever ellipsoid they are given (real - * Cesium ray maths, as `camera.pickEllipsoid` does it). - */ +/** A pinhole viewer `agl` m above ground `ground` m up, looking north. */ function pinhole({ lon, lat, ground, agl, pitch, w = 1600, h = 900 }) { - const position = Cesium.Cartesian3.fromDegrees(lon, lat, ground + agl); - const frame = Cesium.Transforms.eastNorthUpToFixedFrame(position); - const p = pitch * RAD; - const forward = [0, Math.cos(p), Math.sin(p)]; - const up = [0, -Math.sin(p), Math.cos(p)]; - const half = Math.tan(30 * RAD); return { scene: { canvas: { clientWidth: w, clientHeight: h }, globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, }, - camera: { - positionWC: position, - positionCartographic: Cesium.Cartographic.fromDegrees( - lon, - lat, - ground + agl, - ), - pickEllipsoid(point, ellipsoid = Cesium.Ellipsoid.WGS84) { - const sx = ((2 * point.x) / w - 1) * half * (w / h); - const sy = (1 - (2 * point.y) / h) * half; - const local = [0, 1, 2].map( - (i) => forward[i] + sx * (i === 0 ? 1 : 0) + sy * up[i], - ); - const direction = Cesium.Cartesian3.normalize( - Cesium.Matrix4.multiplyByPointAsVector( - frame, - new Cesium.Cartesian3(...local), - new Cesium.Cartesian3(), - ), - new Cesium.Cartesian3(), - ); - const ray = new Cesium.Ray(position, direction); - const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid); - return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined; - }, - computeViewRectangle: () => undefined, - }, + camera: rayCamera({ + lon, + lat, + altitude: ground + agl, + pitch, + width: w, + height: h, + }), }; } diff --git a/src/testSupport/streetLevelFakes.mjs b/src/testSupport/streetLevelFakes.mjs new file mode 100644 index 000000000..a70c07601 --- /dev/null +++ b/src/testSupport/streetLevelFakes.mjs @@ -0,0 +1,151 @@ +/** + * 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. + */ +import * as Cesium from 'cesium'; +import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js'; + +const RAD = Math.PI / 180; + +/** One provider as `getUIState().providers` lists it. */ +export function providerSnapshot(overrides = {}) { + return { + id: 'mapillary', + name: 'Mapillary', + label: 'MAPILLARY', + on: true, + configured: true, + keyRequired: false, + requiresKeyId: 'mapillary', + loading: false, + count: 0, + hint: '', + error: null, + color: '#05cb63', + ...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. + */ +export function fakeStreetLevelProvider({ + id = 'mapillary', + pickPrefix = 'mly:', + html = MAPILLARY_CREDIT_HTML, + nearestImage = async () => null, +} = {}) { + const calls = { activate: 0, deactivate: 0, mount: 0, open: [], unmount: 0 }; + const filters = []; + const stats = { + count: 0, + zoom: null, + kind: null, + loading: false, + hint: '', + error: null, + keyRequired: false, + }; + let context = null; + return { + calls, + filters, + stats, + context: () => context, + id, + name: 'Mapillary', + label: 'MAPILLARY', + requiresKeyId: null, + pickPrefix, + colors: { coverage: '#05cb63' }, + credit: { html }, + create: (providerContext) => { + context = providerContext; + return { + status: async () => ({ configured: true }), + init() {}, + activate: () => calls.activate++, + deactivate: () => calls.deactivate++, + destroy() {}, + refreshCoverage() {}, + setFilter: (filter) => filters.push(filter), + coverageStats: () => ({ ...stats }), + handlePick: () => false, + nearestImage, + viewer: { + mount: async () => calls.mount++, + open: async (imageId) => calls.open.push(imageId), + close() {}, + unmount: () => calls.unmount++, + resize() {}, + onPose: () => () => {}, + }, + }; + }, + }; +} + +/** + * 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. + */ +export function rayCamera({ + lon, + lat, + altitude, + heading = 0, + pitch, + width = 1600, + height = 900, + fovY = 60, + fovX = null, +}) { + const position = Cesium.Cartesian3.fromDegrees(lon, lat, altitude); + const frame = Cesium.Transforms.eastNorthUpToFixedFrame(position); + const h = heading * RAD; + const p = pitch * RAD; + // East-north-up axes of the view: forward, right and up. + const forward = [ + Math.sin(h) * Math.cos(p), + Math.cos(h) * Math.cos(p), + Math.sin(p), + ]; + const right = [Math.cos(h), -Math.sin(h), 0]; + const up = [ + -Math.sin(h) * Math.sin(p), + -Math.cos(h) * Math.sin(p), + Math.cos(p), + ]; + const halfY = Math.tan((fovY / 2) * RAD); + const halfX = + fovX == null ? halfY * (width / height) : Math.tan((fovX / 2) * RAD); + return { + positionWC: position, + positionCartographic: Cesium.Cartographic.fromDegrees(lon, lat, altitude), + pickEllipsoid(point, ellipsoid = Cesium.Ellipsoid.WGS84) { + const sx = ((2 * point.x) / width - 1) * halfX; + const sy = (1 - (2 * point.y) / height) * halfY; + const local = [0, 1, 2].map( + (i) => forward[i] + sx * right[i] + sy * up[i], + ); + const direction = Cesium.Cartesian3.normalize( + Cesium.Matrix4.multiplyByPointAsVector( + frame, + new Cesium.Cartesian3(...local), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const ray = new Cesium.Ray(position, direction); + const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid); + return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined; + }, + computeViewRectangle: () => undefined, + }; +} diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index de622341d..60da5828b 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -72,6 +72,14 @@ 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`; + panelEl.style.right = 'auto'; + panelEl.style.bottom = 'auto'; +} + function isPrecisePointer(event) { const pointerType = event.pointerType || 'mouse'; return pointerType === 'mouse' || pointerType === 'pen'; @@ -433,10 +441,7 @@ export class PanelPositionControls { Math.round(pos.top), panelEl, ); - panelEl.style.left = `${left}px`; - panelEl.style.top = `${top}px`; - panelEl.style.right = 'auto'; - panelEl.style.bottom = 'auto'; + placeAt(panelEl, left, top); if (panelId === 'pp-toggles') { this._pinPanelToRight(panelEl); } @@ -513,10 +518,7 @@ export class PanelPositionControls { */ _liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) { if (panelEl.classList.contains('panel-floating')) return false; - panelEl.style.left = `${Math.round(rect.left)}px`; - panelEl.style.top = `${Math.round(rect.top)}px`; - panelEl.style.right = 'auto'; - panelEl.style.bottom = 'auto'; + placeAt(panelEl, Math.round(rect.left), Math.round(rect.top)); if (!panelEl.classList.contains('collapsed')) { panelEl.style.width = `${Math.round(rect.width)}px`; } @@ -580,10 +582,7 @@ export class PanelPositionControls { const offsetX = event.clientX - rect.left; const offsetY = event.clientY - rect.top; - panelEl.style.left = `${rect.left}px`; - panelEl.style.top = `${rect.top}px`; - panelEl.style.right = 'auto'; - panelEl.style.bottom = 'auto'; + placeAt(panelEl, rect.left, rect.top); panelEl.classList.add('panel-dragging'); this._promotePanelZ(panelEl); diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index 43b9f1132..56a81556a 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -9,6 +9,7 @@ import { REGISTERED_LAYER_IDS, encodeLayerStateParams, } from '../data/layerState.js'; +import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs'; /* ── A small DOM: just what the panel controls touch ───────────────────── */ @@ -296,47 +297,8 @@ const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); /* ── A Street Level layer with one stand-in provider ───────────────────── */ function standInProvider() { - const filters = []; - return { - filters, - def: { - id: 'mapillary', - name: 'Mapillary', - label: 'MAPILLARY', - requiresKeyId: null, - pickPrefix: 'mly:', - colors: { coverage: '#05cb63' }, - credit: { html: 'Mapillary' }, - create: () => ({ - status: async () => ({ configured: true }), - init() {}, - activate() {}, - deactivate() {}, - destroy() {}, - refreshCoverage() {}, - setFilter: (filter) => filters.push(filter), - coverageStats: () => ({ - count: 0, - zoom: null, - kind: null, - loading: false, - hint: '', - error: null, - keyRequired: false, - }), - handlePick: () => false, - nearestImage: async () => null, - viewer: { - mount: async () => {}, - open: async () => {}, - close() {}, - unmount() {}, - resize() {}, - onPose: () => () => {}, - }, - }), - }, - }; + const def = fakeStreetLevelProvider({ html: 'Mapillary' }); + return { filters: def.filters, def }; } /** Every other registered layer, as a module with no options. */ diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index 1ff29d6bc..3a5bc1748 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -5,22 +5,9 @@ import { SINCE_STOPS, sinceStopIndex, } from './streetLevelPresentation.js'; +import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs'; -const provider = (overrides = {}) => ({ - id: 'mapillary', - name: 'Mapillary', - label: 'MAPILLARY', - on: true, - configured: true, - keyRequired: false, - requiresKeyId: 'mapillary', - loading: false, - count: 0, - hint: '', - error: null, - color: '#05cb63', - ...overrides, -}); +const provider = providerSnapshot; function snapshot(overrides = {}) { const base = {