mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
refactor(street-level): drop hover, legend and byte-budget cache; flatten the provider directory
- No pointer cursor over coverage lines (no other layer has one); a click test replaces the hover tests. - No legend: one source, one colour. - The on-globe credit lives in index.js; credits.js is gone. - Billboard glyphs are SVG data URLs with the same geometry, so tests need no canvas stub. - The tile cache keeps the 120 most recently used tiles, same 24 h TTL, instead of a byte budget; listTileLayers was test-only. - providers/mapillary/ is flattened into the layer and its policy merged into one policy.js; the source's method list is a literal in the catalog. - One fakeCesiumViewer for the layer tests. - The fixture gate keeps the steps the unit tests do not cover: coverage and credit, a rejected key, line -> cone -> photo, close, a keyless page. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
34d43c7e3f
commit
e214129e45
41 files changed
+335
-909
No files matched your search
+1
-1
@@ -73,7 +73,7 @@
|
||||
|
||||
- Street Level: Mapillary street-level imagery (free client token, CC BY-SA
|
||||
4.0). A right-rail panel holds an ON/OFF pill, 360°/flat and captured-since
|
||||
filters, a legend and an embedded viewer with EXPAND and FIT/FILL. Coverage
|
||||
filters and an embedded viewer with EXPAND and FIT/FILL. Coverage
|
||||
lines (below 60 km) and image cones drape on terrain and Google 3D tiles;
|
||||
opening a photo frames it with one camera flight. Share links carry the
|
||||
switch, the filters and the panel's collapsed state. A server proxy adds the
|
||||
|
||||
@@ -3063,7 +3063,7 @@ so the whole photo fits.
|
||||
|
||||
Browser gates: `npm run qa:street-level -- --url <server>` runs against real
|
||||
Mapillary; `npm run qa:street-level:fixtures` answers every Mapillary request
|
||||
from fixtures (`providers/mapillary/coverageFixture.mjs`,
|
||||
from fixtures (`src/layers/streetLevel/coverageFixture.mjs`,
|
||||
`scripts/fixtures/street-level/`), but its server (default `:4173`) still needs
|
||||
some `MAPILLARY_CLIENT_TOKEN`. CI runs it `--strict` against `vite preview`.
|
||||
|
||||
|
||||
+1
-1
@@ -260,7 +260,7 @@
|
||||
"node": "./server/providers/mapillary.js"
|
||||
},
|
||||
"./layers/street-level": "./src/layers/streetLevel/index.js",
|
||||
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/source.js",
|
||||
"./tools": "./src/tools/index.js",
|
||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||
"./tools/panel": "./src/tools/panel.js",
|
||||
|
||||
+11
-13
@@ -862,7 +862,7 @@
|
||||
"src/ui/streetLevelPresentation.test.mjs",
|
||||
"src/app/layers/streetLevel.js",
|
||||
"src/tooling/mapillaryProvider.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
|
||||
"src/layers/streetLevel/source.test.mjs",
|
||||
"src/streetLevelMarkup.test.mjs",
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/layers/streetLevel/policy.js",
|
||||
@@ -870,18 +870,16 @@
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/cameraFraming.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/state.js",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
||||
"src/layers/streetLevel/coverage.js",
|
||||
"src/layers/streetLevel/sequences.js",
|
||||
"src/layers/streetLevel/glyphs.js",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||
"src/layers/streetLevel/decode.js",
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
"src/layers/streetLevel/selection.js",
|
||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"src/layers/streetLevel/viewer.js",
|
||||
"src/layers/streetLevel/source.js",
|
||||
"server/providers/mapillary.js",
|
||||
"server/providers/mapillary/tiles.js",
|
||||
"server/providers/mapillary/trim.js",
|
||||
@@ -892,15 +890,15 @@
|
||||
"scripts/fixtures/street-level/mapillaryGraph.mjs",
|
||||
"src/layers/streetLevel/filter.test.mjs",
|
||||
"src/layers/streetLevel/view.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/mapillary.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/mapillary.test.mjs",
|
||||
"src/layers/streetLevel/decode.test.mjs",
|
||||
"src/layers/streetLevel/coverage.test.mjs",
|
||||
"src/layers/streetLevel/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/cameraFraming.test.mjs",
|
||||
"src/ui/streetLevelControls.test.mjs",
|
||||
"src/layers/streetLevel/marker.test.mjs",
|
||||
"src/layers/streetLevel/viewerHost.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.test.mjs",
|
||||
"src/layers/streetLevel/sequences.test.mjs",
|
||||
"src/layers/streetLevel/selection.test.mjs",
|
||||
"src/layers/streetLevel/index.test.mjs",
|
||||
"scripts/qa-overpass-offload.mjs",
|
||||
|
||||
@@ -1190,23 +1190,21 @@
|
||||
"modules": [
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/cameraFraming.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/filter.js",
|
||||
"src/layers/streetLevel/policy.js",
|
||||
"src/layers/streetLevel/state.js",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
||||
"src/layers/streetLevel/coverage.js",
|
||||
"src/layers/streetLevel/sequences.js",
|
||||
"src/layers/streetLevel/glyphs.js",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/decode.js",
|
||||
"src/layers/streetLevel/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
"src/layers/streetLevel/selection.js",
|
||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"src/layers/streetLevel/viewer.js",
|
||||
"src/layers/streetLevel/source.js",
|
||||
"scripts/qa-street-level.mjs"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||
@@ -2130,23 +2128,21 @@
|
||||
"src/ui/streetLevelPresentation.js",
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/cameraFraming.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/filter.js",
|
||||
"src/layers/streetLevel/policy.js",
|
||||
"src/layers/streetLevel/state.js",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
||||
"src/layers/streetLevel/coverage.js",
|
||||
"src/layers/streetLevel/sequences.js",
|
||||
"src/layers/streetLevel/glyphs.js",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/decode.js",
|
||||
"src/layers/streetLevel/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
"src/layers/streetLevel/selection.js",
|
||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"src/layers/streetLevel/viewer.js",
|
||||
"src/layers/streetLevel/source.js",
|
||||
"src/app/layers/streetLevel.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
"src/sources/capability.js",
|
||||
@@ -2645,23 +2641,21 @@
|
||||
"src/maps/imageryHost.js",
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/cameraFraming.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/filter.js",
|
||||
"src/layers/streetLevel/policy.js",
|
||||
"src/layers/streetLevel/state.js",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
||||
"src/layers/streetLevel/coverage.js",
|
||||
"src/layers/streetLevel/sequences.js",
|
||||
"src/layers/streetLevel/glyphs.js",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/decode.js",
|
||||
"src/layers/streetLevel/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
"src/layers/streetLevel/selection.js",
|
||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"src/layers/streetLevel/viewer.js",
|
||||
"src/layers/streetLevel/source.js",
|
||||
"src/app/layers/streetLevel.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
"src/sources/capability.js",
|
||||
|
||||
+5
-153
@@ -12,7 +12,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { tileBounds } from '../src/layers/streetLevel/tileMath.js';
|
||||
import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.mjs';
|
||||
import { encodeCoverageTile } from '../src/layers/streetLevel/coverageFixture.mjs';
|
||||
import {
|
||||
hookRenderErrors,
|
||||
readRenderErrors,
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
} from './fixtures/street-level/mapillaryGraph.mjs';
|
||||
|
||||
const VIEWPORT = { width: 1440, height: 900 };
|
||||
const PHONE = { width: 390, height: 844 };
|
||||
/** Over downtown Sacramento, above the fixture photo line, looking down. */
|
||||
const PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 };
|
||||
const DAY_MS = 86_400_000;
|
||||
@@ -38,7 +37,7 @@ const DAY_MS = 86_400_000;
|
||||
* sequences, plus the photo sequence where PHOTO_LINE crosses the tile.
|
||||
* @returns {Uint8Array} empty below z11, which the layer never asks for
|
||||
*/
|
||||
export function fixtureTile(z, x, y, now = Date.now()) {
|
||||
function fixtureTile(z, x, y, now = Date.now()) {
|
||||
if (z < 11) return new Uint8Array(0);
|
||||
const { west, east, south, north } = tileBounds(x, y, z);
|
||||
const at = (t, lo, hi) => lo + (hi - lo) * t;
|
||||
@@ -387,33 +386,7 @@ async function main() {
|
||||
'fixture runs need a server started with MAPILLARY_CLIENT_TOKEN (CI uses a dummy one)',
|
||||
);
|
||||
|
||||
await step('the panel starts as a collapsed right-rail strip', async () => {
|
||||
const box = await page.evaluate(() => {
|
||||
const panel = document.getElementById('street-level-panel');
|
||||
const r = panel.getBoundingClientRect();
|
||||
return {
|
||||
collapsed: panel.classList.contains('collapsed'),
|
||||
inRail: panel.parentElement.id === 'right-context-rail',
|
||||
body: getComputedStyle(document.getElementById('sl-body')).display,
|
||||
width: r.width,
|
||||
right: r.right,
|
||||
};
|
||||
});
|
||||
assert.equal(box.collapsed, true);
|
||||
assert.equal(box.inRail, true);
|
||||
assert.equal(box.body, 'none');
|
||||
assert.ok(box.width <= 200 && box.right <= VIEWPORT.width);
|
||||
});
|
||||
|
||||
await step('expanding the strip shows the body and legend', async () => {
|
||||
await expandPanel(page);
|
||||
assert.equal(
|
||||
await page.$$eval('#sl-legend li', (items) => items.length),
|
||||
2,
|
||||
'Mapillary and Selected',
|
||||
);
|
||||
});
|
||||
|
||||
await expandPanel(page);
|
||||
if (!configured) {
|
||||
await step('keyless server: KEY REQUIRED gates the controls', () =>
|
||||
assertKeyRequired(page),
|
||||
@@ -443,79 +416,6 @@ async function main() {
|
||||
},
|
||||
);
|
||||
|
||||
await step('the header pill switches the layer off and on', async () => {
|
||||
await press(page, '#sl-status');
|
||||
await statusIs(page, 'OFF');
|
||||
assert.equal(
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.isEnabled('street-level'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal((await getUI(page)).coverage.count, 0);
|
||||
await press(page, '#sl-status');
|
||||
await waitForCoverage(page);
|
||||
await statusIs(page, 'ON');
|
||||
});
|
||||
|
||||
await step('the 360° filter narrows coverage', async () => {
|
||||
const before = (await getUI(page)).coverage.count;
|
||||
await press(page, '[data-sl-pano="pano"]');
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => u.filter.pano === 'pano' && !u.coverage.loading,
|
||||
);
|
||||
const after = (await getUI(page)).coverage.count;
|
||||
// Fixtures hold flat sequences too, so the filter must drop some.
|
||||
assert.ok(
|
||||
fixtures ? after < before : after <= before,
|
||||
`${after} vs ${before}`,
|
||||
);
|
||||
await press(page, '[data-sl-pano="all"]');
|
||||
await uiUntil(
|
||||
page,
|
||||
(u, n) => u.filter.pano === 'all' && u.coverage.count === n,
|
||||
before,
|
||||
);
|
||||
});
|
||||
|
||||
await step(
|
||||
'the SINCE slider narrows coverage and names its cut-off',
|
||||
async () => {
|
||||
const before = (await getUI(page)).coverage.count;
|
||||
const setStop = (index) =>
|
||||
page.$eval(
|
||||
'#sl-since',
|
||||
(input, value) => {
|
||||
input.value = String(value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
},
|
||||
index,
|
||||
);
|
||||
await setStop(5);
|
||||
await uiUntil(
|
||||
page,
|
||||
(u) => u.filter.sinceDays === 365 && !u.coverage.loading,
|
||||
);
|
||||
const after = (await getUI(page)).coverage.count;
|
||||
assert.ok(
|
||||
fixtures ? after < before : after <= before,
|
||||
`${after} vs ${before}`,
|
||||
);
|
||||
assert.match(
|
||||
await page.$eval('#sl-since-label', (node) => node.textContent),
|
||||
/^LAST YEAR · SINCE \d{4}-\d{2}-\d{2}$/,
|
||||
);
|
||||
await setStop(0);
|
||||
await uiUntil(
|
||||
page,
|
||||
(u, n) => u.filter.sinceDays === 0 && u.coverage.count === n,
|
||||
before,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await fixtureStep(
|
||||
'a rejected key reads KEY REJECTED and clears when switched off',
|
||||
async () => {
|
||||
@@ -549,7 +449,6 @@ async function main() {
|
||||
},
|
||||
);
|
||||
|
||||
let imageId = null;
|
||||
await park(page);
|
||||
await waitForCoverage(page);
|
||||
await step(
|
||||
@@ -585,7 +484,6 @@ async function main() {
|
||||
);
|
||||
const { street } = await getUI(page);
|
||||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||||
imageId = street.imageId;
|
||||
await page.waitForSelector('#sl-viewer .mapillary-dom', {
|
||||
timeout: 60_000,
|
||||
});
|
||||
@@ -606,8 +504,8 @@ async function main() {
|
||||
assert.match(view.link, /mapillary\.com\/app\/\?pKey=/);
|
||||
if (fixtures) {
|
||||
assert.ok(
|
||||
fixture.images.some((image) => image.id === imageId),
|
||||
`fixture photo ${imageId}`,
|
||||
fixture.images.some((image) => image.id === street.imageId),
|
||||
`fixture photo ${street.imageId}`,
|
||||
);
|
||||
assert.equal(street.sequenceId, PHOTO_SEQUENCE_ID);
|
||||
}
|
||||
@@ -631,52 +529,6 @@ async function main() {
|
||||
);
|
||||
});
|
||||
|
||||
await step('on a phone the whole photo fits in the panel', async () => {
|
||||
// Width alone drives the phone layout; toggling isMobile would reload.
|
||||
await page.setViewport(PHONE);
|
||||
await page.waitForFunction((w) => innerWidth === w, {}, PHONE.width);
|
||||
await expandPanel(page);
|
||||
await page.evaluate((id) => {
|
||||
void window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.openImage(id);
|
||||
}, imageId);
|
||||
await uiUntil(
|
||||
page,
|
||||
(u, id) => u.street.imageId === id && !u.street.loading,
|
||||
imageId,
|
||||
60_000,
|
||||
);
|
||||
// The panel settles its phone layout over a frame or two.
|
||||
const fits = () => {
|
||||
const inner = document
|
||||
.querySelector('.street-level-panel-inner')
|
||||
.getBoundingClientRect();
|
||||
const meta = document
|
||||
.getElementById('sl-image-meta')
|
||||
.getBoundingClientRect();
|
||||
const viewer = document
|
||||
.getElementById('sl-viewer')
|
||||
.getBoundingClientRect();
|
||||
return {
|
||||
cut: Math.round(meta.bottom - inner.bottom),
|
||||
height: Math.round(viewer.height),
|
||||
overflowX: document.documentElement.scrollWidth > innerWidth,
|
||||
};
|
||||
};
|
||||
await page
|
||||
.waitForFunction(
|
||||
`(${fits})().cut <= 1 && (${fits})().height >= 100`,
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
const fit = await page.evaluate(fits);
|
||||
assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`);
|
||||
assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`);
|
||||
assert.equal(fit.overflowX, false, 'no sideways scroll');
|
||||
await page.setViewport(VIEWPORT);
|
||||
});
|
||||
|
||||
await fixtureStep(
|
||||
'keyless server (second page): KEY REQUIRED gates the controls',
|
||||
async () => {
|
||||
|
||||
@@ -18,9 +18,8 @@ const FETCH_TIMEOUT_MS = 60_000;
|
||||
export const TILE_UPSTREAM_CONCURRENCY = 6;
|
||||
/** Tiles change only when new imagery is processed. */
|
||||
export const TILE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024;
|
||||
/** Per-entry cost on top of its bytes, so empty tiles still count. */
|
||||
const ENTRY_OVERHEAD_BYTES = 1024;
|
||||
/** Trimmed tiles are ~80 KB: a dozen 9-tile views, well under 100 MB. */
|
||||
export const TILE_MEMORY_MAX_TILES = 120;
|
||||
|
||||
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
|
||||
export function mapillaryToken() {
|
||||
@@ -139,39 +138,30 @@ function unlessAborted(promise, signal) {
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {Map<string, {bytes: Buffer, at: number}>} insertion-ordered LRU */
|
||||
/** @type {Map<string, {bytes: Buffer, at: number}>} least recently used first */
|
||||
const memory = new Map();
|
||||
let memoryBytes = 0;
|
||||
/**
|
||||
* Upstream fetches shared per tile, with the requests still waiting on each.
|
||||
* @type {Map<string, {promise: Promise<Buffer>, controller: AbortController, waiters: number}>}
|
||||
*/
|
||||
const inFlight = new Map();
|
||||
const cost = (bytes) => bytes.length + ENTRY_OVERHEAD_BYTES;
|
||||
|
||||
function forget(key) {
|
||||
const hit = memory.get(key);
|
||||
if (!hit) return null;
|
||||
memory.delete(key);
|
||||
memoryBytes -= cost(hit.bytes);
|
||||
return hit;
|
||||
}
|
||||
|
||||
function remember(key, bytes, at = Date.now()) {
|
||||
// One huge (untrimmed) tile must not flush everything else.
|
||||
if (cost(bytes) > TILE_MEMORY_BUDGET_BYTES / 2) return;
|
||||
forget(key);
|
||||
memory.delete(key);
|
||||
memory.set(key, { bytes, at });
|
||||
memoryBytes += cost(bytes);
|
||||
for (const [oldest] of memory) {
|
||||
if (memoryBytes <= TILE_MEMORY_BUDGET_BYTES) break;
|
||||
forget(oldest);
|
||||
for (const oldest of memory.keys()) {
|
||||
if (memory.size <= TILE_MEMORY_MAX_TILES) break;
|
||||
memory.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function recall(key) {
|
||||
const hit = forget(key);
|
||||
if (!hit || Date.now() - hit.at > TILE_TTL_MS) return null;
|
||||
const hit = memory.get(key);
|
||||
if (!hit) return null;
|
||||
if (Date.now() - hit.at > TILE_TTL_MS) {
|
||||
memory.delete(key);
|
||||
return null;
|
||||
}
|
||||
remember(key, hit.bytes, hit.at); // most recently used again
|
||||
return hit.bytes;
|
||||
}
|
||||
@@ -225,11 +215,5 @@ export async function fetchTile(address, { signal } = {}) {
|
||||
/** Test seam: forget every cached and in-flight tile. */
|
||||
export function _resetTileCacheForTest() {
|
||||
memory.clear();
|
||||
memoryBytes = 0;
|
||||
inFlight.clear();
|
||||
}
|
||||
|
||||
/** Test seam: how many tiles memory holds and the bytes charged for them. */
|
||||
export function _tileMemoryForTest() {
|
||||
return { entries: memory.size, bytes: memoryBytes };
|
||||
}
|
||||
@@ -41,16 +41,3 @@ export function stripTileLayers(bytes, dropNames) {
|
||||
}, null);
|
||||
return dropped ? Buffer.from(writer.finish()) : bytes;
|
||||
}
|
||||
|
||||
/** Names of the layers inside a vector tile, in order. */
|
||||
export function listTileLayers(bytes) {
|
||||
const names = [];
|
||||
if (!bytes?.length) return names;
|
||||
new PbfReader(bytes).readFields((tag, _result, pbf) => {
|
||||
if (tag !== TILE_LAYER_FIELD) return;
|
||||
const end = pbf.readVarint() + pbf.pos;
|
||||
names.push(layerName(bytes.subarray(pbf.pos, end)));
|
||||
pbf.pos = end;
|
||||
}, null);
|
||||
return names;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
import { createLayerCatalog } from './catalog.js';
|
||||
import { MAPILLARY_SOURCE_METHODS } from '../layers/streetLevel/providers/mapillary/policy.js';
|
||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
||||
import { createApplicationFlights } from './layers/flights.js';
|
||||
@@ -57,7 +56,7 @@ const SOURCE_METHODS = Object.freeze({
|
||||
earthquakes: ['getSnapshot'],
|
||||
'fire-perimeters': ['getSnapshot'],
|
||||
cables: ['fetch'],
|
||||
mapillary: MAPILLARY_SOURCE_METHODS,
|
||||
mapillary: ['getStatus', 'getTile', 'getSequenceImages'],
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,48 +2,23 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCameraFraming } from './cameraFraming.js';
|
||||
import { fakeCesiumViewer } from '../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
|
||||
const heightOf = (cartesian) =>
|
||||
Cesium.Cartographic.fromCartesian(cartesian).height;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
let current = null;
|
||||
function stop() {
|
||||
const flight = current;
|
||||
current = null;
|
||||
flight?.options.cancel?.();
|
||||
return Boolean(flight);
|
||||
}
|
||||
const camera = {
|
||||
cancelled: 0,
|
||||
flyToBoundingSphere(sphere, options = {}) {
|
||||
stop();
|
||||
current = { sphere, options };
|
||||
flights.push(current);
|
||||
},
|
||||
cancelFlight() {
|
||||
if (stop()) camera.cancelled++;
|
||||
},
|
||||
land() {
|
||||
const flight = current;
|
||||
current = null;
|
||||
flight?.options.complete?.();
|
||||
},
|
||||
};
|
||||
return camera;
|
||||
}
|
||||
|
||||
/**
|
||||
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
|
||||
* under the photo load) and whose globe terrain is `globe`.
|
||||
*/
|
||||
function setup({ sampled, globe = null, altitude = 149 }) {
|
||||
const flights = [];
|
||||
const viewer = fakeCesiumViewer();
|
||||
Object.assign(viewer.scene, {
|
||||
sampleHeightSupported: true,
|
||||
sampleHeight: () => sampled,
|
||||
globe: { getHeight: () => globe },
|
||||
});
|
||||
const state = {
|
||||
services: {},
|
||||
street: {
|
||||
@@ -51,19 +26,13 @@ function setup({ sampled, globe = null, altitude = 149 }) {
|
||||
bearing: 105,
|
||||
altitude,
|
||||
},
|
||||
viewer: {
|
||||
scene: {
|
||||
sampleHeightSupported: true,
|
||||
sampleHeight: () => sampled,
|
||||
globe: { getHeight: () => globe },
|
||||
},
|
||||
camera: flightCamera(flights),
|
||||
},
|
||||
viewer,
|
||||
};
|
||||
return {
|
||||
framing: createCameraFraming({ state }),
|
||||
state,
|
||||
flights,
|
||||
viewer,
|
||||
flights: viewer.flights.list,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,35 +57,37 @@ test('a sample kilometres underground falls back to the globe, then the image al
|
||||
});
|
||||
|
||||
test('cancel stops the framing flight while it is still ours', () => {
|
||||
const { framing, state } = setup({ sampled: 121 });
|
||||
const { camera } = state.viewer;
|
||||
const { framing, viewer } = setup({ sampled: 121 });
|
||||
framing.frame(framing.begin());
|
||||
framing.cancel();
|
||||
assert.equal(camera.cancelled, 1, 'the flight toward the closed photo stops');
|
||||
assert.equal(
|
||||
viewer.flights.cancelled,
|
||||
1,
|
||||
'the flight toward the photo stops',
|
||||
);
|
||||
framing.cancel();
|
||||
assert.equal(camera.cancelled, 1, 'and only once');
|
||||
assert.equal(viewer.flights.cancelled, 1, 'and only once');
|
||||
|
||||
// Re-framing (the next photo) supersedes the first flight, not the claim.
|
||||
framing.frame(framing.begin());
|
||||
framing.frame(framing.begin());
|
||||
framing.cancel();
|
||||
assert.equal(camera.cancelled, 2);
|
||||
assert.equal(viewer.flights.cancelled, 2);
|
||||
});
|
||||
|
||||
test('cancel leaves a landed flight and a newer navigation flight alone', () => {
|
||||
const { framing, state, flights } = setup({ sampled: 121 });
|
||||
const { camera } = state.viewer;
|
||||
const { framing, viewer, flights } = setup({ sampled: 121 });
|
||||
framing.frame(framing.begin());
|
||||
camera.land();
|
||||
viewer.flights.land();
|
||||
framing.cancel();
|
||||
assert.equal(camera.cancelled, 0, 'landed: nothing to stop');
|
||||
assert.equal(viewer.flights.cancelled, 0, 'landed: nothing to stop');
|
||||
|
||||
framing.frame(framing.begin());
|
||||
// A search result flies the globe elsewhere; Cesium cancels ours first.
|
||||
camera.flyToBoundingSphere(null, {});
|
||||
viewer.camera.flyToBoundingSphere(null, {});
|
||||
assert.equal(flights.length, 3);
|
||||
framing.cancel();
|
||||
assert.equal(camera.cancelled, 0, 'the newer flight keeps going');
|
||||
assert.equal(viewer.flights.cancelled, 0, 'the newer flight keeps going');
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
import { passesImageryFilter, resolveFilter } from '../../filter.js';
|
||||
import { isActive } from '../../state.js';
|
||||
import { groundUnderCamera, viewFocus, visibleBbox } from '../../view.js';
|
||||
import { coverageZoomForHeight, tilesForBbox } from '../../tileMath.js';
|
||||
import { passesImageryFilter, resolveFilter } from './filter.js';
|
||||
import { isActive } from './state.js';
|
||||
import { groundUnderCamera, viewFocus, visibleBbox } from './view.js';
|
||||
import { coverageZoomForHeight, tilesForBbox } from './tileMath.js';
|
||||
import {
|
||||
COLORS,
|
||||
COVERAGE_LINE_WIDTH_PX,
|
||||
+3
-3
@@ -2,11 +2,11 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCoverage, ZOOM_IN_HINT } from './coverage.js';
|
||||
import { freshCoverage } from '../../state.js';
|
||||
import { freshCoverage } from './state.js';
|
||||
import { encodeCoverageTile } from './coverageFixture.mjs';
|
||||
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
|
||||
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
|
||||
import { COVERAGE_MAX_SEQUENCES, COVERAGE_MAX_TILES } from './policy.js';
|
||||
import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js';
|
||||
import { lonToTileX, latToTileY, tileBounds } from './tileMath.js';
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
// Node has no WebGL context to report line-width limits; a browser's
|
||||
File renamed without changes.
@@ -1,32 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* A static Cesium credit shown while the layer draws: CC BY-SA imagery needs
|
||||
* visible attribution that goes away with the imagery.
|
||||
*/
|
||||
export function createCredit(html) {
|
||||
let shown = null;
|
||||
|
||||
function show(viewer) {
|
||||
if (shown || !viewer?.creditDisplay) return;
|
||||
try {
|
||||
const credit = new Cesium.Credit(html, true);
|
||||
viewer.creditDisplay.addStaticCredit(credit);
|
||||
shown = credit;
|
||||
} catch {
|
||||
/* credit display unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
function hide(viewer) {
|
||||
if (!shown) return;
|
||||
try {
|
||||
viewer?.creditDisplay?.removeStaticCredit?.(shown);
|
||||
} catch {
|
||||
/* credit display already torn down */
|
||||
}
|
||||
shown = null;
|
||||
}
|
||||
|
||||
return { show, hide };
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { PbfReader } from 'pbf';
|
||||
import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { tileLocalToLonLat } from '../../tileMath.js';
|
||||
import { tileLocalToLonLat } from './tileMath.js';
|
||||
|
||||
/**
|
||||
* Decode a `mly1_public` coverage tile (z11–14) into sequences. The z14
|
||||
File renamed without changes.
@@ -1,48 +1,27 @@
|
||||
/** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */
|
||||
/** Billboard glyphs as SVG data URLs: the image cone, the 360° ring and the position marker. */
|
||||
|
||||
const _cache = new Map();
|
||||
const px = (n) => Number(n.toFixed(2));
|
||||
|
||||
/** 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;
|
||||
function svg(size, body) {
|
||||
const markup = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${body}</svg>`;
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(markup)}`;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
function wedge(size, radius, halfAngle, color, opacity) {
|
||||
const c = size / 2;
|
||||
const dx = px(radius * Math.sin(halfAngle));
|
||||
const dy = px(radius * Math.cos(halfAngle));
|
||||
const r = px(radius);
|
||||
return `<path d="M${c} ${c}L${px(c - dx)} ${px(c - dy)}A${r} ${r} 0 0 1 ${px(c + dx)} ${px(c - dy)}Z" fill="${color}" fill-opacity="${opacity}"/>`;
|
||||
}
|
||||
|
||||
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;
|
||||
function ring(size, radius, color, width, opacity) {
|
||||
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="none" stroke="${color}" stroke-width="${px(width)}" stroke-opacity="${opacity}"/>`;
|
||||
}
|
||||
|
||||
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();
|
||||
function dot(size, radius, color, outline, width) {
|
||||
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="${color}" stroke="${outline}" stroke-width="${px(width)}"/>`;
|
||||
}
|
||||
|
||||
/** Cone glyph pointing "up"; rotate the billboard by the compass angle. */
|
||||
@@ -51,25 +30,28 @@ export function imageConeGlyph({
|
||||
color = '#e8eaed',
|
||||
pano = false,
|
||||
} = {}) {
|
||||
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),
|
||||
);
|
||||
});
|
||||
const shape = pano
|
||||
? ring(size, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5)
|
||||
: wedge(size, size * 0.46, 0.62, color, 0.42);
|
||||
return svg(
|
||||
size,
|
||||
shape +
|
||||
dot(
|
||||
size,
|
||||
size * 0.16,
|
||||
color,
|
||||
'rgba(10,10,15,0.85)',
|
||||
Math.max(1, size * 0.05),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
dot(ctx, c, size * 0.2, color, '#0a0a0f', 2);
|
||||
ring(ctx, c, size * 0.3, color, 1.5, 0.8);
|
||||
});
|
||||
return svg(
|
||||
size,
|
||||
wedge(size, size * 0.48, 0.55, color, 0.55) +
|
||||
dot(size, size * 0.2, color, '#0a0a0f', 2) +
|
||||
ring(size, size * 0.3, color, 1.5, 0.8),
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createState } from './state.js';
|
||||
import { createMarker } from './marker.js';
|
||||
import { createCameraFraming } from './cameraFraming.js';
|
||||
import { createCredit } from './credits.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createViewerHost } from './viewerHost.js';
|
||||
import { normalizeFilter, sameFilter } from './filter.js';
|
||||
import { keyStatusLabel, STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||
import { createCoverage } from './providers/mapillary/coverage.js';
|
||||
import { createSequences } from './providers/mapillary/sequences.js';
|
||||
import { createMapillaryViewer } from './providers/mapillary/viewer.js';
|
||||
import {
|
||||
keyStatusLabel,
|
||||
MAPILLARY_CREDIT_HTML,
|
||||
MAPILLARY_KEY_ID,
|
||||
} from './providers/mapillary/policy.js';
|
||||
STREET_LEVEL_LAYER_ID,
|
||||
} from './policy.js';
|
||||
import { createCoverage } from './coverage.js';
|
||||
import { createSequences } from './sequences.js';
|
||||
import { createMapillaryViewer } from './viewer.js';
|
||||
|
||||
export { STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||
|
||||
@@ -33,8 +34,8 @@ const COVERAGE_OFF = Object.freeze({
|
||||
* The Street Level layer: Mapillary coverage lines at street zoom, a selected
|
||||
* sequence's image cones, and the photo viewer in the panel.
|
||||
* @param {{source: object, services?: object, photoViewer?: object}} options
|
||||
* `source` is providers/mapillary/source.js or a stand-in; `photoViewer`
|
||||
* stands in for the MapillaryJS adapter (providers/mapillary/viewer.js).
|
||||
* `source` is source.js or a stand-in; `photoViewer` stands in for the
|
||||
* MapillaryJS adapter (viewer.js).
|
||||
*/
|
||||
export function createStreetLevelLayer({
|
||||
source,
|
||||
@@ -44,7 +45,6 @@ export function createStreetLevelLayer({
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, parts };
|
||||
const credit = createCredit(MAPILLARY_CREDIT_HTML);
|
||||
parts.marker = createMarker(context);
|
||||
parts.framing = createCameraFraming(context);
|
||||
parts.coverage = createCoverage({ state, source });
|
||||
@@ -97,12 +97,25 @@ export function createStreetLevelLayer({
|
||||
return keyCheck;
|
||||
}
|
||||
|
||||
/** The on-globe credit while the layer draws: CC BY-SA imagery needs it. */
|
||||
let credit = null;
|
||||
function showCredit(viewer) {
|
||||
if (credit || !viewer?.creditDisplay) return;
|
||||
credit = new Cesium.Credit(MAPILLARY_CREDIT_HTML, true);
|
||||
viewer.creditDisplay.addStaticCredit(credit);
|
||||
}
|
||||
function hideCredit(viewer) {
|
||||
if (!credit) return;
|
||||
viewer?.creditDisplay?.removeStaticCredit?.(credit);
|
||||
credit = null;
|
||||
}
|
||||
|
||||
function startDrawing() {
|
||||
const viewer = state.viewer;
|
||||
if (!viewer) return;
|
||||
parts.sequences.setVisible(true);
|
||||
parts.coverage.attach(viewer);
|
||||
credit.show(viewer);
|
||||
showCredit(viewer);
|
||||
// Without a known key, a switch-on asks again: the server may be back,
|
||||
// or have a key now.
|
||||
if (['status', 'no-key'].includes(state.coverage.blocked)) checkKey();
|
||||
@@ -116,7 +129,7 @@ export function createStreetLevelLayer({
|
||||
parts.coverage.unblock();
|
||||
parts.sequences.clearSelection();
|
||||
parts.sequences.setVisible(false);
|
||||
credit.hide(state.viewer);
|
||||
hideCredit(state.viewer);
|
||||
}
|
||||
|
||||
/** The share link's `mapillary` switch: off, the layer draws nothing. */
|
||||
|
||||
@@ -2,83 +2,13 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createStreetLevelLayer } from './index.js';
|
||||
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
||||
import { MAPILLARY_CREDIT_HTML } from './policy.js';
|
||||
import {
|
||||
fakeCesiumViewer,
|
||||
fakeMapillarySource,
|
||||
fakePhotoViewer,
|
||||
rayCamera,
|
||||
} from '../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
/**
|
||||
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
|
||||
* click handler, camera events, a primitive list and a credit display. With
|
||||
* `view`, the camera looks straight down on Sacramento from 900 m, so
|
||||
* coverage asks for tiles.
|
||||
*/
|
||||
function fakeViewer({ view = false } = {}) {
|
||||
const credits = [];
|
||||
/** Cesium's flight bookkeeping: a new flight cancels the current one. */
|
||||
let flight = null;
|
||||
const flights = { started: 0, cancelled: 0 };
|
||||
const stopFlight = () => {
|
||||
const current = flight;
|
||||
flight = null;
|
||||
current?.cancel?.();
|
||||
return Boolean(current);
|
||||
};
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
style: {},
|
||||
// Keep Cesium's handler on the canvas; there is no real document here.
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
...(view ? { clientWidth: 100, clientHeight: 100 } : {}),
|
||||
});
|
||||
const camera = Object.assign(
|
||||
view
|
||||
? rayCamera({
|
||||
lon: -121.4944,
|
||||
lat: 38.5816,
|
||||
altitude: 900,
|
||||
pitch: -90,
|
||||
width: 100,
|
||||
height: 100,
|
||||
})
|
||||
: {},
|
||||
{
|
||||
changed: new Cesium.Event(),
|
||||
moveStart: new Cesium.Event(),
|
||||
moveEnd: new Cesium.Event(),
|
||||
flyToBoundingSphere(sphere, options = {}) {
|
||||
stopFlight();
|
||||
flight = options;
|
||||
flights.started++;
|
||||
},
|
||||
cancelFlight() {
|
||||
if (stopFlight()) flights.cancelled++;
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
credits,
|
||||
flights,
|
||||
scene: {
|
||||
canvas,
|
||||
primitives: { add: (p) => p, remove() {} },
|
||||
groundPrimitives: { add: (p) => p, remove() {} },
|
||||
// Enough of a scene for the position marker to clamp to the ground.
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
updateHeight: () => () => {},
|
||||
getHeight: () => undefined,
|
||||
},
|
||||
camera,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => credits.push(credit),
|
||||
removeStaticCredit: (credit) =>
|
||||
credits.splice(credits.indexOf(credit), 1),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A layer over `source` and `photoViewer`, initialised on a stand-in viewer
|
||||
* and (unless `enable` is false) enabled.
|
||||
@@ -93,18 +23,15 @@ async function startLayer(
|
||||
} = {},
|
||||
) {
|
||||
const saved = globalThis.document;
|
||||
// A pose moves the position marker, whose glyph is drawn on a canvas.
|
||||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||
globalThis.document = Object.assign(new EventTarget(), {
|
||||
createElement: () => ({ getContext: () => drawing }),
|
||||
});
|
||||
// The click handler listens for Esc on the document.
|
||||
globalThis.document = new EventTarget();
|
||||
// Skip Cesium's one-time terrain table download for draped lines.
|
||||
t.mock.method(
|
||||
Cesium.GroundPolylinePrimitive,
|
||||
'initializeTerrainHeights',
|
||||
async () => {},
|
||||
);
|
||||
const viewer = fakeViewer({ view });
|
||||
const viewer = fakeCesiumViewer({ view });
|
||||
const layer = createStreetLevelLayer({ source, photoViewer });
|
||||
layer.init(viewer);
|
||||
if (enable) layer.enable(viewer);
|
||||
|
||||
File renamed without changes.
@@ -3,13 +3,6 @@ import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMarker } from './marker.js';
|
||||
|
||||
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
|
||||
const noop = () => {};
|
||||
const context2d = new Proxy({}, { get: () => noop, set: () => true });
|
||||
globalThis.document ??= {
|
||||
createElement: () => ({ getContext: () => context2d }),
|
||||
};
|
||||
|
||||
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
|
||||
function setup() {
|
||||
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
/** Identity and shared tuning for the Street Level layer. */
|
||||
/** Identity and tuning for the Street Level layer and its Mapillary imagery. */
|
||||
export const STREET_LEVEL_LAYER_ID = 'street-level';
|
||||
export const MAPILLARY_KEY_ID = 'mapillary';
|
||||
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
|
||||
|
||||
/** Pick id of the viewer position marker. */
|
||||
export const POSITION_PICK_ID = 'sl:pos';
|
||||
|
||||
/** The selection highlight and the position marker. */
|
||||
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
|
||||
export const PICK_PREFIX = Object.freeze({
|
||||
root: 'mly:',
|
||||
sequence: 'mly:seq:',
|
||||
image: 'mly:img:',
|
||||
});
|
||||
|
||||
/** Mapillary green for lines and cones, GEV cyan for the selection, amber for the marker. */
|
||||
export const COLORS = Object.freeze({
|
||||
coverage: '#05cb63',
|
||||
selected: '#00d4ff',
|
||||
position: '#ffb300',
|
||||
});
|
||||
@@ -19,6 +29,44 @@ export const MAX_SINCE_DAYS = 36_500;
|
||||
/** Filter the layer starts with: all imagery, any date. */
|
||||
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
|
||||
|
||||
/** Camera-driven coverage refresh. */
|
||||
export const COVERAGE_MOVE_DEBOUNCE_MS = 320;
|
||||
export const COVERAGE_MAX_TILES = 9;
|
||||
/** Shown when Mapillary refuses the server's token (401/403). */
|
||||
export const KEY_REJECTED_MESSAGE =
|
||||
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
|
||||
/** Shown while Mapillary rate-limits tile requests (429). */
|
||||
export const RATE_LIMITED_MESSAGE =
|
||||
'Coverage requests are being rate-limited — they resume on their own';
|
||||
/**
|
||||
* 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;
|
||||
export const COVERAGE_MAX_SEQUENCES = 6000;
|
||||
export const COVERAGE_LINE_WIDTH_PX = 2.5;
|
||||
|
||||
/** Per-sequence image cones after a sequence is selected. */
|
||||
export const SEQUENCE_IMAGES_LIMIT = 2000;
|
||||
export const IMAGE_CONE_SIZE_PX = 26;
|
||||
export const IMAGE_CONE_MIN_SPACING_M = 3;
|
||||
|
||||
/** 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';
|
||||
|
||||
/** Deep link to an image on mapillary.com, as the web app shares them. */
|
||||
export function mapillaryImageUrl(imageId) {
|
||||
const id = String(imageId || '').trim();
|
||||
if (!id) return 'https://www.mapillary.com/app/';
|
||||
return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`;
|
||||
}
|
||||
|
||||
/** Fields requested for a sequence's images. */
|
||||
export const SEQUENCE_IMAGE_FIELDS =
|
||||
'id,captured_at,compass_angle,geometry,is_pano';
|
||||
|
||||
/** The key gate's label for the pill and the layer list, or null when the key is fine. */
|
||||
export function keyStatusLabel({ keyRequired, keyRejected }) {
|
||||
if (keyRejected) return 'KEY REJECTED';
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { COLORS as SHARED_COLORS } from '../../policy.js';
|
||||
|
||||
/** Identity and tuning for Street Level's Mapillary imagery. */
|
||||
export const MAPILLARY_KEY_ID = 'mapillary';
|
||||
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
|
||||
|
||||
/** Methods a source (./source.js or a stand-in) must have. */
|
||||
export const MAPILLARY_SOURCE_METHODS = Object.freeze([
|
||||
'getStatus',
|
||||
'getTile',
|
||||
'getSequenceImages',
|
||||
]);
|
||||
|
||||
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
|
||||
export const PICK_PREFIX = Object.freeze({
|
||||
root: 'mly:',
|
||||
sequence: 'mly:seq:',
|
||||
image: 'mly:img:',
|
||||
});
|
||||
|
||||
/** Mapillary green for lines, cones and the legend; GEV cyan for selection. */
|
||||
export const COLORS = Object.freeze({
|
||||
coverage: '#05cb63',
|
||||
selected: SHARED_COLORS.selected,
|
||||
});
|
||||
|
||||
/** Camera-driven coverage refresh. */
|
||||
export const COVERAGE_MOVE_DEBOUNCE_MS = 320;
|
||||
export const COVERAGE_MAX_TILES = 9;
|
||||
/** Shown when Mapillary refuses the server's token (401/403). */
|
||||
export const KEY_REJECTED_MESSAGE =
|
||||
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
|
||||
/** Shown while Mapillary rate-limits tile requests (429). */
|
||||
export const RATE_LIMITED_MESSAGE =
|
||||
'Coverage requests are being rate-limited — they resume on their own';
|
||||
/**
|
||||
* 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;
|
||||
export const COVERAGE_MAX_SEQUENCES = 6000;
|
||||
export const COVERAGE_LINE_WIDTH_PX = 2.5;
|
||||
|
||||
/** Per-sequence image cones after a sequence is selected. */
|
||||
export const SEQUENCE_IMAGES_LIMIT = 2000;
|
||||
export const IMAGE_CONE_SIZE_PX = 26;
|
||||
export const IMAGE_CONE_MIN_SPACING_M = 3;
|
||||
|
||||
/** 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';
|
||||
|
||||
/** Deep link to an image on mapillary.com, as the web app shares them. */
|
||||
export function mapillaryImageUrl(imageId) {
|
||||
const id = String(imageId || '').trim();
|
||||
if (!id) return 'https://www.mapillary.com/app/';
|
||||
return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`;
|
||||
}
|
||||
|
||||
/** Fields requested for a sequence's images. */
|
||||
export const SEQUENCE_IMAGE_FIELDS =
|
||||
'id,captured_at,compass_angle,geometry,is_pano';
|
||||
@@ -1,9 +1,12 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { POSITION_PICK_ID, STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||
import { PICK_PREFIX } from './providers/mapillary/policy.js';
|
||||
import { sequenceIdFromPick } from './providers/mapillary/coverage.js';
|
||||
import {
|
||||
PICK_PREFIX,
|
||||
POSITION_PICK_ID,
|
||||
STREET_LEVEL_LAYER_ID,
|
||||
} from './policy.js';
|
||||
import { sequenceIdFromPick } from './coverage.js';
|
||||
|
||||
/** One click/hover handler for coverage lines, image cones and the marker. */
|
||||
/** One click handler for coverage lines, image cones and the marker. */
|
||||
export function createSelection({ state, parts }) {
|
||||
const { picking, input } = state.services;
|
||||
|
||||
@@ -51,67 +54,6 @@ export function createSelection({ state, parts }) {
|
||||
parts.sequences.clearSelection();
|
||||
}
|
||||
|
||||
/** The pointer over the globe, and whether a frame is queued to pick it. */
|
||||
let pointer = null;
|
||||
let hoverQueued = false;
|
||||
let hoverCursor = false;
|
||||
/** Buttons held: any of them, with any modifier, is a camera drag. */
|
||||
let pointerButtons = 0;
|
||||
let removeHoverWatchers = null;
|
||||
|
||||
function setHoverCursor(canvas, hit) {
|
||||
if (hit === hoverCursor) return;
|
||||
canvas.style.cursor = hit ? 'pointer' : '';
|
||||
hoverCursor = hit;
|
||||
}
|
||||
|
||||
function pickHover() {
|
||||
hoverQueued = false;
|
||||
const canvas = state.viewer?.scene?.canvas;
|
||||
// Switched off (or torn down) since the frame was queued, or dragging.
|
||||
if (!canvas || !state.enabled || !state.clickHandler || pointerButtons)
|
||||
return;
|
||||
let hit = false;
|
||||
try {
|
||||
const picked = state.viewer.scene.pick(pointer);
|
||||
hit = ownsPick(
|
||||
picking?.resolvePickId ? picking.resolvePickId(picked) : picked?.id,
|
||||
);
|
||||
} catch {
|
||||
hit = false;
|
||||
}
|
||||
setHoverCursor(canvas, hit);
|
||||
}
|
||||
|
||||
/** Pick the pointer on the next frame; at most one pick a frame. */
|
||||
function queueHoverPick() {
|
||||
if (hoverQueued || !pointer || pointerButtons) return;
|
||||
hoverQueued = true;
|
||||
requestAnimationFrame(pickHover);
|
||||
}
|
||||
|
||||
/** Pointer cursor over anything this layer owns. */
|
||||
function onMove(movement) {
|
||||
if (!state.enabled) return;
|
||||
pointer = Cesium.Cartesian2.clone(movement.endPosition, pointer);
|
||||
queueHoverPick();
|
||||
}
|
||||
|
||||
/** Track held buttons, and pick again once the camera rests under a still pointer. */
|
||||
function watchHover(viewer) {
|
||||
const canvas = viewer.scene.canvas;
|
||||
const onButtons = (event) => {
|
||||
pointerButtons = event.buttons ?? 0;
|
||||
};
|
||||
const types = ['pointerdown', 'pointermove', 'pointerup'];
|
||||
for (const type of types) canvas.addEventListener?.(type, onButtons);
|
||||
const removeEnd = viewer.camera?.moveEnd?.addEventListener(queueHoverPick);
|
||||
removeHoverWatchers = () => {
|
||||
for (const type of types) canvas.removeEventListener?.(type, onButtons);
|
||||
removeEnd?.();
|
||||
};
|
||||
}
|
||||
|
||||
function install(viewer) {
|
||||
if (state.clickHandler) return;
|
||||
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
@@ -121,11 +63,6 @@ export function createSelection({ state, parts }) {
|
||||
onClick,
|
||||
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
||||
);
|
||||
state.clickHandler.setInputAction(
|
||||
onMove,
|
||||
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
|
||||
);
|
||||
watchHover(viewer);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick);
|
||||
}
|
||||
@@ -133,16 +70,7 @@ export function createSelection({ state, parts }) {
|
||||
function uninstall() {
|
||||
// Nothing to undo for a layer that was never enabled.
|
||||
if (!state.clickHandler) return;
|
||||
removeHoverWatchers?.();
|
||||
removeHoverWatchers = null;
|
||||
pointer = null;
|
||||
pointerButtons = 0;
|
||||
if (hoverCursor && state.viewer?.scene?.canvas) {
|
||||
state.viewer.scene.canvas.style.cursor = '';
|
||||
hoverCursor = false;
|
||||
}
|
||||
if (state.clickHandler && !state.clickHandler.isDestroyed())
|
||||
state.clickHandler.destroy();
|
||||
if (!state.clickHandler.isDestroyed()) state.clickHandler.destroy();
|
||||
state.clickHandler = null;
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
|
||||
|
||||
@@ -3,17 +3,9 @@ import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createSelection } from './selection.js';
|
||||
|
||||
/**
|
||||
* The selection installed on a stand-in viewer. Animation frames queue until
|
||||
* `frame()` runs them.
|
||||
*/
|
||||
/** The selection installed on a stand-in viewer, with what its clicks reach. */
|
||||
function harness({ selected = false } = {}) {
|
||||
const saved = {
|
||||
document: globalThis.document,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
};
|
||||
const frames = [];
|
||||
globalThis.requestAnimationFrame = (task) => frames.push(task);
|
||||
const saved = globalThis.document;
|
||||
globalThis.document = new EventTarget();
|
||||
// The document's keydown listeners, so a test can hand them any target.
|
||||
const keyListeners = new Set();
|
||||
@@ -31,26 +23,17 @@ function harness({ selected = false } = {}) {
|
||||
if (type === 'keydown') keyListeners.delete(listener);
|
||||
removeListener(type, listener, options);
|
||||
};
|
||||
const sequences = { selected, cleared: 0 };
|
||||
const picks = [];
|
||||
/** What the pointer is over: a line this layer owns, or nothing. */
|
||||
const sequences = { selected, cleared: 0, selects: [] };
|
||||
const opened = [];
|
||||
/** What the pointer is over: a pick id, or nothing. */
|
||||
const scene = { under: null };
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
style: {},
|
||||
// Keep Cesium's handler on the canvas; there is no real document here.
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
});
|
||||
const camera = { moveEnd: new Cesium.Event() };
|
||||
const viewer = {
|
||||
camera,
|
||||
scene: {
|
||||
canvas,
|
||||
pick(position) {
|
||||
picks.push(position);
|
||||
return scene.under && { id: scene.under };
|
||||
},
|
||||
},
|
||||
scene: { canvas, pick: () => scene.under && { id: scene.under } },
|
||||
};
|
||||
const state = {
|
||||
viewer,
|
||||
@@ -68,20 +51,24 @@ function harness({ selected = false } = {}) {
|
||||
state,
|
||||
parts: {
|
||||
sequences: {
|
||||
select: (id) => sequences.selects.push(id),
|
||||
clearSelection() {
|
||||
sequences.cleared++;
|
||||
sequences.selected = false;
|
||||
},
|
||||
},
|
||||
openImage: (id) => opened.push(id),
|
||||
},
|
||||
});
|
||||
selection.install(viewer);
|
||||
const action = (type) =>
|
||||
state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]);
|
||||
const onMove = action('MOUSE_MOVE');
|
||||
return {
|
||||
state,
|
||||
sequences,
|
||||
opened,
|
||||
scene,
|
||||
click: () => action('LEFT_CLICK')({ position: { x: 10, y: 10 } }),
|
||||
/** A keydown reaching the document from `target`; the event as handled. */
|
||||
key(key, target, { defaultPrevented = false } = {}) {
|
||||
const event = {
|
||||
@@ -95,94 +82,32 @@ function harness({ selected = false } = {}) {
|
||||
for (const listener of [...keyListeners]) listener(event);
|
||||
return event;
|
||||
},
|
||||
scene,
|
||||
canvas,
|
||||
picks,
|
||||
move: (x) => onMove({ endPosition: { x, y: 10 } }),
|
||||
camera,
|
||||
/** A pointer event on the canvas with `buttons` held. */
|
||||
buttons(type, buttons) {
|
||||
const event = new Event(type);
|
||||
Object.defineProperty(event, 'buttons', { value: buttons });
|
||||
canvas.dispatchEvent(event);
|
||||
},
|
||||
frame() {
|
||||
for (const task of frames.splice(0)) task();
|
||||
},
|
||||
restore() {
|
||||
selection.uninstall();
|
||||
Object.assign(globalThis, saved);
|
||||
globalThis.document = saved;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('hover picks once a frame, at the latest position, and shows a pointer over a line', () => {
|
||||
test('a click on a line selects its sequence, on a cone opens its image, elsewhere does nothing', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.scene.under = 'mly:seq:1';
|
||||
for (let x = 0; x < 5; x++) h.move(x);
|
||||
h.frame();
|
||||
assert.deepEqual(
|
||||
h.picks.map(({ x }) => x),
|
||||
[4],
|
||||
'one pick for the burst',
|
||||
);
|
||||
assert.equal(h.canvas.style.cursor, 'pointer');
|
||||
h.scene.under = null;
|
||||
h.move(6);
|
||||
h.frame();
|
||||
assert.equal(h.canvas.style.cursor, '', 'off the line');
|
||||
h.frame();
|
||||
assert.equal(h.picks.length, 2, 'a still pointer costs nothing');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a drag with any button picks nothing, and a release with a modifier held does not stick', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
for (const held of [1, 2, 4]) {
|
||||
h.buttons('pointerdown', held);
|
||||
h.move(held);
|
||||
h.frame();
|
||||
h.scene.under = 'mly:seq:abc';
|
||||
h.click();
|
||||
assert.deepEqual(h.sequences.selects, ['abc']);
|
||||
h.scene.under = 'mly:img:42';
|
||||
h.click();
|
||||
assert.deepEqual(h.opened, ['42']);
|
||||
for (const under of [null, 'cctv:7', 'sl:pos']) {
|
||||
h.scene.under = under;
|
||||
h.click();
|
||||
}
|
||||
assert.equal(h.picks.length, 0, 'left, right and middle drags');
|
||||
// Released with Shift held: Cesium's plain LEFT_UP never fires.
|
||||
h.buttons('pointerup', 0);
|
||||
h.move(5);
|
||||
h.frame();
|
||||
assert.equal(h.picks.length, 1, 'hover picks again');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('when the camera rests, a still pointer is picked again so the cursor cannot stick', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.scene.under = 'mly:seq:1';
|
||||
h.move(5);
|
||||
h.frame();
|
||||
assert.equal(h.canvas.style.cursor, 'pointer');
|
||||
// The camera moves the line out from under a still pointer.
|
||||
h.scene.under = null;
|
||||
h.camera.moveEnd.raiseEvent();
|
||||
h.frame();
|
||||
assert.equal(h.picks.length, 2);
|
||||
assert.equal(h.canvas.style.cursor, '');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a hover frame queued before the layer went off does not pick', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.move(2);
|
||||
h.state.enabled = false;
|
||||
h.frame();
|
||||
assert.equal(h.picks.length, 0, 'the layer went off');
|
||||
assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]);
|
||||
// Mapillary switched off under a layer that is on: lines are not drawn.
|
||||
h.state.providerOn = false;
|
||||
h.scene.under = 'mly:seq:abc';
|
||||
h.click();
|
||||
assert.deepEqual(h.sequences.selects, ['abc']);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { imageConeGlyph } from '../../glyphs.js';
|
||||
import { passesImageryFilter, resolveFilter } from '../../filter.js';
|
||||
import { createHorizonCull, metresBetween } from '../../view.js';
|
||||
import { imageConeGlyph } from './glyphs.js';
|
||||
import { passesImageryFilter, resolveFilter } from './filter.js';
|
||||
import { createHorizonCull, metresBetween } from './view.js';
|
||||
import {
|
||||
COLORS,
|
||||
IMAGE_CONE_MIN_SPACING_M,
|
||||
-7
@@ -3,13 +3,6 @@ import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createSequences } from './sequences.js';
|
||||
|
||||
// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op.
|
||||
const noop = () => {};
|
||||
const context2d = new Proxy({}, { get: () => noop, set: () => true });
|
||||
globalThis.document ??= {
|
||||
createElement: () => ({ getContext: () => context2d }),
|
||||
};
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** A billboard collection that remembers the cones on the globe. */
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -27,8 +27,7 @@ registerHooks({
|
||||
return { format: 'module', shortCircuit: true, source };
|
||||
},
|
||||
});
|
||||
const { createMapillaryViewer } =
|
||||
await import('./providers/mapillary/viewer.js');
|
||||
const { createMapillaryViewer } = await import('./viewer.js');
|
||||
|
||||
/**
|
||||
* A viewer host over `adapter`. `marker` records where the position marker
|
||||
|
||||
@@ -17,7 +17,7 @@ import { createWeatherSource } from '../layers/weather/source.js';
|
||||
import { createCycloneSource } from '../layers/cyclones/source.js';
|
||||
import { createWindSource } from '../layers/wind/source.js';
|
||||
import { createFirmsSource } from '../layers/firms/source.js';
|
||||
import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js';
|
||||
import { createMapillarySource } from '../layers/streetLevel/source.js';
|
||||
import { createReferenceSources } from '../sources/reference.js';
|
||||
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Shared Street Level test stand-ins: Mapillary source, photo viewer, ray-casting camera. */
|
||||
/** Shared Street Level test stand-ins: Mapillary source, photo viewer, Cesium viewer, ray-casting camera. */
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
@@ -48,6 +48,87 @@ export function fakePhotoViewer({ pose = null } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in Cesium viewer a layer can be enabled on: a canvas for the click
|
||||
* handler, camera events with Cesium's flight bookkeeping (`flights` counts
|
||||
* them, lists them and can `land` the current one), primitive lists and a
|
||||
* credit display. With `view`, the camera looks straight down on Sacramento
|
||||
* from 900 m, so coverage asks for tiles.
|
||||
*/
|
||||
export function fakeCesiumViewer({ view = false } = {}) {
|
||||
const credits = [];
|
||||
let flight = null;
|
||||
const flights = {
|
||||
started: 0,
|
||||
cancelled: 0,
|
||||
/** Every flight asked for, oldest first: `{ sphere, options }`. */
|
||||
list: [],
|
||||
land() {
|
||||
const current = flight;
|
||||
flight = null;
|
||||
current?.options.complete?.();
|
||||
},
|
||||
};
|
||||
const stopFlight = () => {
|
||||
const current = flight;
|
||||
flight = null;
|
||||
current?.options.cancel?.();
|
||||
return Boolean(current);
|
||||
};
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
style: {},
|
||||
// Keep Cesium's handler on the canvas; there is no real document here.
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
...(view ? { clientWidth: 100, clientHeight: 100 } : {}),
|
||||
});
|
||||
const camera = Object.assign(
|
||||
view
|
||||
? rayCamera({
|
||||
lon: -121.4944,
|
||||
lat: 38.5816,
|
||||
altitude: 900,
|
||||
pitch: -90,
|
||||
width: 100,
|
||||
height: 100,
|
||||
})
|
||||
: {},
|
||||
{
|
||||
changed: new Cesium.Event(),
|
||||
moveStart: new Cesium.Event(),
|
||||
moveEnd: new Cesium.Event(),
|
||||
flyToBoundingSphere(sphere, options = {}) {
|
||||
stopFlight();
|
||||
flight = { sphere, options };
|
||||
flights.list.push(flight);
|
||||
flights.started++;
|
||||
},
|
||||
cancelFlight() {
|
||||
if (stopFlight()) flights.cancelled++;
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
credits,
|
||||
flights,
|
||||
scene: {
|
||||
canvas,
|
||||
primitives: { add: (p) => p, remove() {} },
|
||||
groundPrimitives: { add: (p) => p, remove() {} },
|
||||
// Enough of a scene for the position marker to clamp to the ground.
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
updateHeight: () => () => {},
|
||||
getHeight: () => undefined,
|
||||
},
|
||||
camera,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => credits.push(credit),
|
||||
removeStaticCredit: (credit) =>
|
||||
credits.splice(credits.indexOf(credit), 1),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and
|
||||
* `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets.
|
||||
|
||||
@@ -5,19 +5,15 @@ import {
|
||||
mapillaryProxy,
|
||||
TILE_ROUTE_MAX_PER_MIN,
|
||||
} from 'gods-eye-view/server/providers/mapillary';
|
||||
import {
|
||||
listTileLayers,
|
||||
stripTileLayers,
|
||||
} from '../../server/providers/mapillary/trim.js';
|
||||
import { stripTileLayers } from '../../server/providers/mapillary/trim.js';
|
||||
import {
|
||||
TILE_MAX_BYTES,
|
||||
TILE_MEMORY_BUDGET_BYTES,
|
||||
TILE_MEMORY_MAX_TILES,
|
||||
TILE_UPSTREAM_CONCURRENCY,
|
||||
TILE_TTL_MS,
|
||||
fetchTile,
|
||||
parseTilePath,
|
||||
_resetTileCacheForTest,
|
||||
_tileMemoryForTest,
|
||||
} from '../../server/providers/mapillary/tiles.js';
|
||||
|
||||
/** Build a minimal MVT: layers with a name, a version and one opaque feature. */
|
||||
@@ -545,26 +541,18 @@ test('a tile held in memory past its TTL is fetched again', async (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the memory cache evicts the least recently used tile past its budget', async () => {
|
||||
const MB = 1024 * 1024;
|
||||
// Three 30 MB tiles fit in the budget, a fourth does not.
|
||||
const big = tile([{ name: 'sequence', payload: Buffer.alloc(30 * MB) }]);
|
||||
assert.ok(3 * big.length < TILE_MEMORY_BUDGET_BYTES);
|
||||
assert.ok(4 * big.length > TILE_MEMORY_BUDGET_BYTES);
|
||||
test('the memory cache evicts the least recently used tile past its size', async () => {
|
||||
await withUpstream(
|
||||
() => ok(big),
|
||||
async ({ calls, call }) => {
|
||||
() => ok(),
|
||||
async ({ calls }) => {
|
||||
const get = async (x) =>
|
||||
(await call('/api/mapillary/tiles', `/coverage/14/${x}/20`)).headers[
|
||||
'x-gev-cache'
|
||||
];
|
||||
for (const x of [20, 21, 22]) assert.equal(await get(x), 'upstream');
|
||||
assert.equal(await get(20), 'memory', 'A is used again');
|
||||
assert.equal(await get(23), 'upstream');
|
||||
assert.equal(_tileMemoryForTest().entries, 3);
|
||||
for (const x of [20, 22, 23]) assert.equal(await get(x), 'memory');
|
||||
assert.equal(await get(21), 'upstream', 'B was evicted');
|
||||
assert.equal(calls.length, 5);
|
||||
(await fetchTile(parseTilePath(`/coverage/14/${x}/20`))).source;
|
||||
for (let x = 0; x < TILE_MEMORY_MAX_TILES; x++) await get(x);
|
||||
assert.equal(await get(0), 'memory', 'the first is used again');
|
||||
await get(TILE_MEMORY_MAX_TILES);
|
||||
assert.equal(await get(0), 'memory', 'so it stays');
|
||||
assert.equal(await get(1), 'upstream', 'the least recently used went');
|
||||
assert.equal(calls.length, TILE_MEMORY_MAX_TILES + 2);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -578,7 +566,6 @@ test('dropping a layer keeps the others byte-for-byte', () => {
|
||||
{ name: 'overview' },
|
||||
]);
|
||||
const trimmed = stripTileLayers(bytes, ['image']);
|
||||
assert.deepEqual(listTileLayers(trimmed), ['sequence', 'overview']);
|
||||
assert.deepEqual(trimmed, tile([{ name: 'sequence' }, { name: 'overview' }]));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,89 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { fixtureTile } from '../../scripts/qa-street-level.mjs';
|
||||
import {
|
||||
answerMapillaryRequest,
|
||||
describeCall,
|
||||
PHOTO_LINE,
|
||||
PHOTO_SEQUENCE_ID,
|
||||
photoImages,
|
||||
THUMB_HOST,
|
||||
} from '../../scripts/fixtures/street-level/mapillaryGraph.mjs';
|
||||
import { redact } from '../../scripts/qa-browserEvidence.mjs';
|
||||
|
||||
test('fixture tiles give the hermetic gate something real to filter', async () => {
|
||||
const { decodeCoverageTile } =
|
||||
await import('../layers/streetLevel/providers/mapillary/decode.js');
|
||||
const now = Date.UTC(2026, 9, 1);
|
||||
const street = decodeCoverageTile(fixtureTile(14, 2662, 6286, now), {
|
||||
x: 2662,
|
||||
y: 6286,
|
||||
z: 14,
|
||||
});
|
||||
const grid = street.sequences.filter((s) => s.id !== PHOTO_SEQUENCE_ID);
|
||||
assert.equal(grid.length, 8);
|
||||
const pano = grid.filter((s) => s.isPano).length;
|
||||
assert.ok(pano > 0 && pano < 8, '360° and flat both present');
|
||||
const year = 365 * 86_400_000;
|
||||
const old = grid.filter((s) => now - s.capturedAt > year).length;
|
||||
assert.ok(old > 0 && old < 8, 'recent and older both present');
|
||||
assert.equal(fixtureTile(3, 1, 3, now).length, 0, 'nothing from orbit');
|
||||
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nor in between');
|
||||
});
|
||||
|
||||
/* ── Hermetic photo flow: the photo line and the Graph fixtures ────────── */
|
||||
|
||||
test('the photo sequence runs through the parked view, clipped to each tile it crosses', async () => {
|
||||
const { decodeCoverageTile } =
|
||||
await import('../layers/streetLevel/providers/mapillary/decode.js');
|
||||
const { lonToTileX, latToTileY } =
|
||||
await import('../layers/streetLevel/tileMath.js');
|
||||
const x = lonToTileX(PHOTO_LINE.lon, 14);
|
||||
const rows = new Set(
|
||||
[PHOTO_LINE.south, PHOTO_LINE.north].map((lat) => latToTileY(lat, 14)),
|
||||
);
|
||||
assert.ok(rows.size > 1, 'the line crosses a tile edge');
|
||||
let south = Infinity;
|
||||
let north = -Infinity;
|
||||
for (const y of rows) {
|
||||
const line = decodeCoverageTile(fixtureTile(14, x, y), {
|
||||
x,
|
||||
y,
|
||||
z: 14,
|
||||
}).sequences.find((s) => s.id === PHOTO_SEQUENCE_ID);
|
||||
assert.ok(line, `tile 14/${x}/${y} carries the photo line`);
|
||||
for (const [lon, lat] of line.parts[0]) {
|
||||
assert.ok(Math.abs(lon - PHOTO_LINE.lon) < 1e-4);
|
||||
south = Math.min(south, lat);
|
||||
north = Math.max(north, lat);
|
||||
}
|
||||
}
|
||||
assert.ok(Math.abs(south - PHOTO_LINE.south) < 1e-4);
|
||||
assert.ok(Math.abs(north - PHOTO_LINE.north) < 1e-4);
|
||||
// Far away there is no photo line, only the grid.
|
||||
const elsewhere = decodeCoverageTile(fixtureTile(14, 100, 100), {
|
||||
x: 100,
|
||||
y: 100,
|
||||
z: 14,
|
||||
});
|
||||
assert.equal(
|
||||
elsewhere.sequences.some((s) => s.id === PHOTO_SEQUENCE_ID),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('the fixture photos: one sequence, 360° and flat, spaced past the cone thinning', () => {
|
||||
const images = photoImages(Date.UTC(2026, 9, 1));
|
||||
assert.ok(images.length >= 3);
|
||||
assert.ok(images.some((image) => image.isPano));
|
||||
assert.ok(images.some((image) => !image.isPano));
|
||||
for (let i = 1; i < images.length; i++) {
|
||||
const gap = (images[i].lat - images[i - 1].lat) * 110_540;
|
||||
assert.ok(gap > 3, `gap ${gap}`);
|
||||
}
|
||||
assert.equal(new Set(images.map((image) => image.id)).size, images.length);
|
||||
});
|
||||
|
||||
const graph = (path, params) =>
|
||||
`https://graph.mapillary.com/${path}?${new URLSearchParams({ access_token: 'MLY|0|qa-fixture', ...params })}`;
|
||||
const answer = (url, method = 'GET') => {
|
||||
@@ -94,22 +19,6 @@ const answer = (url, method = 'GET') => {
|
||||
};
|
||||
};
|
||||
|
||||
test('the Graph fixtures answer the provider’s sequence lookups', () => {
|
||||
const images = photoImages();
|
||||
const sequence = answer(
|
||||
graph('images', {
|
||||
sequence_ids: PHOTO_SEQUENCE_ID,
|
||||
fields: 'id,geometry,compass_angle,captured_at,is_pano',
|
||||
limit: '2000',
|
||||
}),
|
||||
);
|
||||
assert.equal(sequence.json.data.length, images.length);
|
||||
assert.deepEqual(
|
||||
answer(graph('images', { sequence_ids: 'other', fields: 'id' })).json.data,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('the Graph fixtures answer every call MapillaryJS makes to open a photo', () => {
|
||||
const [first, second] = photoImages();
|
||||
const spatial = answer(
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from './globePanel.js';
|
||||
import { panelRuntime } from '../app/globePanelRuntime.js';
|
||||
import { composeCatalog, coreTools } from './index.js';
|
||||
import { MAPILLARY_GRAPH_HOST } from '../layers/streetLevel/providers/mapillary/policy.js';
|
||||
import { MAPILLARY_GRAPH_HOST } from '../layers/streetLevel/policy.js';
|
||||
|
||||
/** Whether a CSP source list allows a URL (exact origin or `https://*.host`). */
|
||||
function allows(sources, url) {
|
||||
|
||||
@@ -24,8 +24,8 @@ const setAttr = (node, key, value) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills the Street Level panel body: filters, legend and viewer. The panel
|
||||
* chrome itself belongs to the application shell.
|
||||
* Fills the Street Level panel body: filters and viewer. The panel chrome
|
||||
* itself belongs to the application shell.
|
||||
*/
|
||||
export class StreetLevelControls {
|
||||
constructor({ root, layer, actions }) {
|
||||
@@ -58,7 +58,6 @@ export class StreetLevelControls {
|
||||
errorText: byId('sl-error-text'),
|
||||
sinceRange: byId('sl-since'),
|
||||
sinceLabel: byId('sl-since-label'),
|
||||
legend: byId('sl-legend'),
|
||||
viewerWrap: byId('sl-viewer-wrap'),
|
||||
viewerExpand: byId('sl-viewer-expand'),
|
||||
viewerClose: byId('sl-viewer-close'),
|
||||
@@ -376,7 +375,6 @@ export class StreetLevelControls {
|
||||
setAttr(el.sinceRange, 'aria-valuetext', view.since.label);
|
||||
}
|
||||
}
|
||||
setProp(el.legend, 'hidden', !view.legend);
|
||||
}
|
||||
|
||||
_renderViewer(view) {
|
||||
|
||||
@@ -223,7 +223,6 @@ function panelDom({ fullscreen = true, popover = false } = {}) {
|
||||
add(controls, 'button', { dataset: { slPano: pano } });
|
||||
add(controls, 'input', { id: 'sl-since' });
|
||||
add(controls, 'output', { id: 'sl-since-label' });
|
||||
add(root, 'ul', { id: 'sl-legend' });
|
||||
add(root, 'div', { id: 'sl-coverage-meta' });
|
||||
return { document, root, wrap };
|
||||
}
|
||||
@@ -475,11 +474,6 @@ test('a layer that is on with Mapillary switched off reads OFF, and the pill bri
|
||||
dom.root.querySelector('#sl-coverage-meta').textContent,
|
||||
/Mapillary is off in this view/,
|
||||
);
|
||||
assert.equal(
|
||||
dom.root.querySelector('#sl-legend').hidden,
|
||||
true,
|
||||
'no legend',
|
||||
);
|
||||
status.click();
|
||||
await settle();
|
||||
assert.deepEqual(calls.setParams, [
|
||||
|
||||
@@ -130,7 +130,6 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
||||
null,
|
||||
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
|
||||
since: presentSince(filter.sinceDays, now),
|
||||
legend: state.providerOn !== false,
|
||||
viewer: presentViewer(state),
|
||||
meta: presentMeta(state),
|
||||
};
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
/* ── Filters and legend ──────────────────────────────────────────────────── */
|
||||
/* ── Filters ──────────────────────────────────────────────────── */
|
||||
.sl-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -196,42 +196,6 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sl-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sl-legend li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text);
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sl-legend[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sl-legend-swatch {
|
||||
width: 14px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
background: var(--sl-green);
|
||||
}
|
||||
|
||||
/* COLORS.selected in src/layers/streetLevel/policy.js */
|
||||
.sl-legend-swatch.is-selected {
|
||||
background: #00d4ff;
|
||||
}
|
||||
|
||||
/* ── Embedded viewer ─────────────────────────────────────────────────────── */
|
||||
.sl-viewer-wrap {
|
||||
position: relative;
|
||||
|
||||
@@ -124,10 +124,6 @@
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
|
||||
</div>
|
||||
</fieldset>
|
||||
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours">
|
||||
<li><i class="sl-legend-swatch"></i><span>Mapillary</span></li>
|
||||
<li><i class="sl-legend-swatch is-selected"></i><span>Selected</span></li>
|
||||
</ul>
|
||||
<div id="sl-coverage-meta" class="sl-meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user