mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
refactor(street-level): remove the remaining duplicated code and fakes
A duplicate scan of every module the PR adds (repeated 4-line blocks, normalised) after gekh's dedup round left these: - glyphs.js: the cone, ring and position glyphs share cachedGlyph(), wedge(), ring() and dot(). Checked with a recording canvas: all 20 draw calls keep the same path and fill/stroke state, so the glyphs are pixel-identical. - panelPositionControls.js: one placeAt() for the three places that pin a panel's top-left corner as a fixed window. - tiles.js: one warn() for the three background-failure logs. - Tests: src/testSupport/streetLevelFakes.mjs holds the provider snapshot (uiState + presentation tests), the stand-in provider (core + controls tests) and one real-ray pinhole camera (view + coverage tests, which had each written their own). The coverage line-of-sight test still fails when refresh() stops passing its focus. - qa-street-level.mjs: one waitForCoverage() for the five copies of the "on, drawn, settled, not key-gated" wait. No behaviour change: unit 5862 pass, fixture gate 16/16, panel gate 5/5. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d70f468781
commit
c2f1b54d00
12 files changed
+281
-345
No files matched your search
@@ -274,6 +274,7 @@
|
|||||||
"src/ui/styles.js",
|
"src/ui/styles.js",
|
||||||
"src/ui/shellLifecycle.test.mjs",
|
"src/ui/shellLifecycle.test.mjs",
|
||||||
"src/testSupport/readStylesheet.mjs",
|
"src/testSupport/readStylesheet.mjs",
|
||||||
|
"src/testSupport/streetLevelFakes.mjs",
|
||||||
"src/splitFlap.js",
|
"src/splitFlap.js",
|
||||||
"style.css",
|
"style.css",
|
||||||
"src/ui/styles/overlays.css",
|
"src/ui/styles/overlays.css",
|
||||||
|
|||||||
+22
-54
@@ -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. */
|
/** Without a key on the server the panel gates its controls and says why. */
|
||||||
async function assertKeylessGate(page) {
|
async function assertKeylessGate(page) {
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
@@ -360,15 +377,7 @@ async function main() {
|
|||||||
origin: 'user',
|
origin: 'user',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await page.waitForFunction(
|
await waitForCoverage(page);
|
||||||
() => {
|
|
||||||
const u = window.__godsEyeView.dataManager.layers
|
|
||||||
.get('street-level')
|
|
||||||
.module.getUIState();
|
|
||||||
return u.coverage.count > 0 && !u.coverage.loading;
|
|
||||||
},
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
);
|
|
||||||
const info = await panel();
|
const info = await panel();
|
||||||
assert.equal(info.controlsDisabled, false);
|
assert.equal(info.controlsDisabled, false);
|
||||||
// Cesium paints on-screen credits a frame or two after they register.
|
// 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);
|
assert.equal((await ui()).coverage.count, 0);
|
||||||
await page.click('#sl-status');
|
await page.click('#sl-status');
|
||||||
await page.waitForFunction(
|
await waitForCoverage(page);
|
||||||
() => {
|
|
||||||
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 },
|
|
||||||
);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.$eval('#sl-status', (node) =>
|
await page.$eval('#sl-status', (node) =>
|
||||||
node.getAttribute('aria-pressed'),
|
node.getAttribute('aria-pressed'),
|
||||||
@@ -458,18 +456,7 @@ async function main() {
|
|||||||
'lighting the chip turns the layer back on with coverage',
|
'lighting the chip turns the layer back on with coverage',
|
||||||
async () => {
|
async () => {
|
||||||
await page.click(chip);
|
await page.click(chip);
|
||||||
await page.waitForFunction(
|
await waitForCoverage(page);
|
||||||
() => {
|
|
||||||
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 },
|
|
||||||
);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.$eval(chip, (node) => node.getAttribute('aria-pressed')),
|
await page.$eval(chip, (node) => node.getAttribute('aria-pressed')),
|
||||||
'true',
|
'true',
|
||||||
@@ -614,17 +601,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
fixture.tiles = 'ok';
|
fixture.tiles = 'ok';
|
||||||
await page.click('#sl-status');
|
await page.click('#sl-status');
|
||||||
await page.waitForFunction(
|
await waitForCoverage(page);
|
||||||
() => {
|
|
||||||
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 statusText('ON');
|
await statusText('ON');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -806,16 +783,7 @@ async function main() {
|
|||||||
.module.getUIState().surface === 'draped',
|
.module.getUIState().surface === 'draped',
|
||||||
{ timeout: 10_000 },
|
{ timeout: 10_000 },
|
||||||
);
|
);
|
||||||
const coverageLoaded = () =>
|
const coverageLoaded = () => waitForCoverage(page, { timeout: 60_000 });
|
||||||
page.waitForFunction(
|
|
||||||
() => {
|
|
||||||
const u = window.__godsEyeView.dataManager.layers
|
|
||||||
.get('street-level')
|
|
||||||
.module.getUIState();
|
|
||||||
return u.coverage.count > 0 && !u.coverage.loading;
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
);
|
|
||||||
await coverageLoaded();
|
await coverageLoaded();
|
||||||
assert.equal((await surface()).surface, 'draped', 'draped on Esri');
|
assert.equal((await surface()).surface, 'draped', 'draped on Esri');
|
||||||
const photoreal = await page.evaluate(() =>
|
const photoreal = await page.evaluate(() =>
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ import {
|
|||||||
mapillaryToken,
|
mapillaryToken,
|
||||||
} from './constants.js';
|
} 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. */
|
/** Thrown for a request this proxy refuses before contacting Mapillary. */
|
||||||
export class TileRequestError extends Error {
|
export class TileRequestError extends Error {
|
||||||
constructor(message, status = 400) {
|
constructor(message, status = 400) {
|
||||||
@@ -224,10 +229,7 @@ function writeDisk(address, bytes, at = null) {
|
|||||||
})
|
})
|
||||||
.catch(async (error) => {
|
.catch(async (error) => {
|
||||||
await fsp.rm(tmp, { force: true }).catch(() => {});
|
await fsp.rm(tmp, { force: true }).catch(() => {});
|
||||||
console.warn(
|
warn('tile cache write failed', error);
|
||||||
'[Mapillary Proxy] tile cache write failed:',
|
|
||||||
error?.message || error,
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (newest()) _diskWrites.delete(file);
|
if (newest()) _diskWrites.delete(file);
|
||||||
@@ -248,10 +250,7 @@ function scheduleSweep(root) {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (_sweepWarned) return;
|
if (_sweepWarned) return;
|
||||||
_sweepWarned = true;
|
_sweepWarned = true;
|
||||||
console.warn(
|
warn('tile cache sweep failed', error);
|
||||||
'[Mapillary Proxy] tile cache sweep failed:',
|
|
||||||
error?.message || error,
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (_sweeping === tracked) _sweeping = null;
|
if (_sweeping === tracked) _sweeping = null;
|
||||||
@@ -417,10 +416,7 @@ function trim(address, bytes) {
|
|||||||
try {
|
try {
|
||||||
return stripTileLayers(bytes, address.dropLayers);
|
return stripTileLayers(bytes, address.dropLayers);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
warn('tile trim failed, serving raw', error);
|
||||||
'[Mapillary Proxy] tile trim failed, serving raw:',
|
|
||||||
error?.message || error,
|
|
||||||
);
|
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,81 +6,76 @@
|
|||||||
|
|
||||||
const _cache = new Map();
|
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');
|
const element = document.createElement('canvas');
|
||||||
element.width = size;
|
element.width = size;
|
||||||
element.height = size;
|
element.height = size;
|
||||||
|
draw(element.getContext('2d'), size / 2);
|
||||||
|
_cache.set(key, element);
|
||||||
return 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. */
|
/** Cone glyph pointing "up"; rotate the billboard by the compass angle. */
|
||||||
export function imageConeGlyph({
|
export function imageConeGlyph({
|
||||||
size = 32,
|
size = 32,
|
||||||
color = '#e8eaed',
|
color = '#e8eaed',
|
||||||
pano = false,
|
pano = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const key = `cone:${size}:${color}:${pano}`;
|
return cachedGlyph(`cone:${size}:${color}:${pano}`, size, (ctx, c) => {
|
||||||
if (_cache.has(key)) return _cache.get(key);
|
if (pano) ring(ctx, c, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5);
|
||||||
const element = canvas(size);
|
else wedge(ctx, c, size * 0.46, 0.62, color, 0.42);
|
||||||
const ctx = element.getContext('2d');
|
dot(
|
||||||
const c = size / 2;
|
ctx,
|
||||||
if (!pano) {
|
c,
|
||||||
ctx.beginPath();
|
size * 0.16,
|
||||||
ctx.moveTo(c, c);
|
color,
|
||||||
ctx.arc(c, c, size * 0.46, -Math.PI / 2 - 0.62, -Math.PI / 2 + 0.62);
|
'rgba(10,10,15,0.85)',
|
||||||
ctx.closePath();
|
Math.max(1, size * 0.05),
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pulsing-looking marker for the image the viewer currently shows. */
|
/** Pulsing-looking marker for the image the viewer currently shows. */
|
||||||
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
|
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
|
||||||
const key = `pos:${size}:${color}`;
|
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
|
||||||
if (_cache.has(key)) return _cache.get(key);
|
wedge(ctx, c, size * 0.48, 0.55, color, 0.55);
|
||||||
const element = canvas(size);
|
dot(ctx, c, size * 0.2, color, '#0a0a0f', 2);
|
||||||
const ctx = element.getContext('2d');
|
ring(ctx, c, size * 0.3, color, 1.5, 0.8);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
@@ -3,64 +3,7 @@ import test from 'node:test';
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createStreetLevelLayer } from './index.js';
|
import { createStreetLevelLayer } from './index.js';
|
||||||
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
||||||
|
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
/**
|
|
||||||
* 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: () => () => {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
|
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { SWAP_MAX_WAIT_MS, createCoverage } from './coverage.js';
|
import { SWAP_MAX_WAIT_MS, createCoverage } from './coverage.js';
|
||||||
import { encodeCoverageTile } from './coverageFixture.mjs';
|
import { encodeCoverageTile } from './coverageFixture.mjs';
|
||||||
|
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
|
||||||
import {
|
import {
|
||||||
COLORS,
|
COLORS,
|
||||||
COVERAGE_MAX_SEQUENCES,
|
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();
|
coverage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */
|
||||||
* 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).
|
|
||||||
*/
|
|
||||||
function tiltedCamera({ lon, lat, height, heading, pitch }) {
|
function tiltedCamera({ lon, lat, height, heading, pitch }) {
|
||||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, height);
|
return rayCamera({
|
||||||
const frameAt = Cesium.Transforms.eastNorthUpToFixedFrame(position);
|
lon,
|
||||||
function ray(point) {
|
lat,
|
||||||
const h = (heading + (point.x / 100 - 0.5) * 60) * RAD;
|
altitude: height,
|
||||||
const p = (pitch + (0.5 - point.y / 100) * 40) * RAD;
|
heading,
|
||||||
const local = new Cesium.Cartesian3(
|
pitch,
|
||||||
Math.sin(h) * Math.cos(p),
|
width: 100,
|
||||||
Math.cos(h) * Math.cos(p),
|
height: 100,
|
||||||
Math.sin(p),
|
fovX: 60,
|
||||||
);
|
fovY: 40,
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,22 +2,10 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { composeUIState, summarizeCoverage } from './uiState.js';
|
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||||
import { COLORS } from './policy.js';
|
import { COLORS } from './policy.js';
|
||||||
|
import { providerSnapshot } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
const provider = (overrides = {}) => ({
|
const provider = (overrides = {}) =>
|
||||||
id: 'mapillary',
|
providerSnapshot({ count: 10, ...overrides });
|
||||||
name: 'Mapillary',
|
|
||||||
label: 'MAPILLARY',
|
|
||||||
on: true,
|
|
||||||
configured: true,
|
|
||||||
keyRequired: false,
|
|
||||||
requiresKeyId: 'mapillary',
|
|
||||||
loading: false,
|
|
||||||
count: 10,
|
|
||||||
hint: '',
|
|
||||||
error: null,
|
|
||||||
color: '#05cb63',
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
const base = {
|
const base = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
filter: { pano: 'all', sinceDays: 0 },
|
filter: { pano: 'all', sinceDays: 0 },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
viewFocus,
|
viewFocus,
|
||||||
visibleBbox,
|
visibleBbox,
|
||||||
} from './view.js';
|
} from './view.js';
|
||||||
|
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
const RAD = Math.PI / 180;
|
const RAD = Math.PI / 180;
|
||||||
/** A viewer 300 m above a street 1,600 m up (Denver-like). */
|
/** 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 viewer `agl` m above ground `ground` m up, looking north. */
|
||||||
* 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).
|
|
||||||
*/
|
|
||||||
function pinhole({ lon, lat, ground, agl, pitch, w = 1600, h = 900 }) {
|
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 {
|
return {
|
||||||
scene: {
|
scene: {
|
||||||
canvas: { clientWidth: w, clientHeight: h },
|
canvas: { clientWidth: w, clientHeight: h },
|
||||||
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
|
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||||
},
|
},
|
||||||
camera: {
|
camera: rayCamera({
|
||||||
positionWC: position,
|
lon,
|
||||||
positionCartographic: Cesium.Cartographic.fromDegrees(
|
lat,
|
||||||
lon,
|
altitude: ground + agl,
|
||||||
lat,
|
pitch,
|
||||||
ground + agl,
|
width: w,
|
||||||
),
|
height: h,
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -72,6 +72,14 @@ function fitWindowExtent(value, minimum, viewportExtent) {
|
|||||||
* Only a mouse or pen lifts and resizes a portable panel. On a phone the
|
* 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.
|
* 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) {
|
function isPrecisePointer(event) {
|
||||||
const pointerType = event.pointerType || 'mouse';
|
const pointerType = event.pointerType || 'mouse';
|
||||||
return pointerType === 'mouse' || pointerType === 'pen';
|
return pointerType === 'mouse' || pointerType === 'pen';
|
||||||
@@ -433,10 +441,7 @@ export class PanelPositionControls {
|
|||||||
Math.round(pos.top),
|
Math.round(pos.top),
|
||||||
panelEl,
|
panelEl,
|
||||||
);
|
);
|
||||||
panelEl.style.left = `${left}px`;
|
placeAt(panelEl, left, top);
|
||||||
panelEl.style.top = `${top}px`;
|
|
||||||
panelEl.style.right = 'auto';
|
|
||||||
panelEl.style.bottom = 'auto';
|
|
||||||
if (panelId === 'pp-toggles') {
|
if (panelId === 'pp-toggles') {
|
||||||
this._pinPanelToRight(panelEl);
|
this._pinPanelToRight(panelEl);
|
||||||
}
|
}
|
||||||
@@ -513,10 +518,7 @@ export class PanelPositionControls {
|
|||||||
*/
|
*/
|
||||||
_liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) {
|
_liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) {
|
||||||
if (panelEl.classList.contains('panel-floating')) return false;
|
if (panelEl.classList.contains('panel-floating')) return false;
|
||||||
panelEl.style.left = `${Math.round(rect.left)}px`;
|
placeAt(panelEl, Math.round(rect.left), Math.round(rect.top));
|
||||||
panelEl.style.top = `${Math.round(rect.top)}px`;
|
|
||||||
panelEl.style.right = 'auto';
|
|
||||||
panelEl.style.bottom = 'auto';
|
|
||||||
if (!panelEl.classList.contains('collapsed')) {
|
if (!panelEl.classList.contains('collapsed')) {
|
||||||
panelEl.style.width = `${Math.round(rect.width)}px`;
|
panelEl.style.width = `${Math.round(rect.width)}px`;
|
||||||
}
|
}
|
||||||
@@ -580,10 +582,7 @@ export class PanelPositionControls {
|
|||||||
const offsetX = event.clientX - rect.left;
|
const offsetX = event.clientX - rect.left;
|
||||||
const offsetY = event.clientY - rect.top;
|
const offsetY = event.clientY - rect.top;
|
||||||
|
|
||||||
panelEl.style.left = `${rect.left}px`;
|
placeAt(panelEl, rect.left, rect.top);
|
||||||
panelEl.style.top = `${rect.top}px`;
|
|
||||||
panelEl.style.right = 'auto';
|
|
||||||
panelEl.style.bottom = 'auto';
|
|
||||||
panelEl.classList.add('panel-dragging');
|
panelEl.classList.add('panel-dragging');
|
||||||
this._promotePanelZ(panelEl);
|
this._promotePanelZ(panelEl);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
REGISTERED_LAYER_IDS,
|
REGISTERED_LAYER_IDS,
|
||||||
encodeLayerStateParams,
|
encodeLayerStateParams,
|
||||||
} from '../data/layerState.js';
|
} from '../data/layerState.js';
|
||||||
|
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
/* ── A small DOM: just what the panel controls touch ───────────────────── */
|
/* ── 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 ───────────────────── */
|
/* ── A Street Level layer with one stand-in provider ───────────────────── */
|
||||||
|
|
||||||
function standInProvider() {
|
function standInProvider() {
|
||||||
const filters = [];
|
const def = fakeStreetLevelProvider({ html: 'Mapillary' });
|
||||||
return {
|
return { filters: def.filters, def };
|
||||||
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: () => () => {},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every other registered layer, as a module with no options. */
|
/** Every other registered layer, as a module with no options. */
|
||||||
|
|||||||
@@ -5,22 +5,9 @@ import {
|
|||||||
SINCE_STOPS,
|
SINCE_STOPS,
|
||||||
sinceStopIndex,
|
sinceStopIndex,
|
||||||
} from './streetLevelPresentation.js';
|
} from './streetLevelPresentation.js';
|
||||||
|
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
const provider = (overrides = {}) => ({
|
const provider = providerSnapshot;
|
||||||
id: 'mapillary',
|
|
||||||
name: 'Mapillary',
|
|
||||||
label: 'MAPILLARY',
|
|
||||||
on: true,
|
|
||||||
configured: true,
|
|
||||||
keyRequired: false,
|
|
||||||
requiresKeyId: 'mapillary',
|
|
||||||
loading: false,
|
|
||||||
count: 0,
|
|
||||||
hint: '',
|
|
||||||
error: null,
|
|
||||||
color: '#05cb63',
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
function snapshot(overrides = {}) {
|
function snapshot(overrides = {}) {
|
||||||
const base = {
|
const base = {
|
||||||
|
|||||||
Reference in new issue
Block a user