mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04: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
|
- 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
|
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;
|
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
|
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
|
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
|
Browser gates: `npm run qa:street-level -- --url <server>` runs against real
|
||||||
Mapillary; `npm run qa:street-level:fixtures` answers every Mapillary request
|
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
|
`scripts/fixtures/street-level/`), but its server (default `:4173`) still needs
|
||||||
some `MAPILLARY_CLIENT_TOKEN`. CI runs it `--strict` against `vite preview`.
|
some `MAPILLARY_CLIENT_TOKEN`. CI runs it `--strict` against `vite preview`.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -260,7 +260,7 @@
|
|||||||
"node": "./server/providers/mapillary.js"
|
"node": "./server/providers/mapillary.js"
|
||||||
},
|
},
|
||||||
"./layers/street-level": "./src/layers/streetLevel/index.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": "./src/tools/index.js",
|
||||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||||
"./tools/panel": "./src/tools/panel.js",
|
"./tools/panel": "./src/tools/panel.js",
|
||||||
|
|||||||
+11
-13
@@ -862,7 +862,7 @@
|
|||||||
"src/ui/streetLevelPresentation.test.mjs",
|
"src/ui/streetLevelPresentation.test.mjs",
|
||||||
"src/app/layers/streetLevel.js",
|
"src/app/layers/streetLevel.js",
|
||||||
"src/tooling/mapillaryProvider.test.mjs",
|
"src/tooling/mapillaryProvider.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
|
"src/layers/streetLevel/source.test.mjs",
|
||||||
"src/streetLevelMarkup.test.mjs",
|
"src/streetLevelMarkup.test.mjs",
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
@@ -870,18 +870,16 @@
|
|||||||
"src/layers/streetLevel/view.js",
|
"src/layers/streetLevel/view.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/cameraFraming.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
"src/layers/streetLevel/coverage.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
"src/layers/streetLevel/sequences.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
|
||||||
"src/layers/streetLevel/glyphs.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/tileMath.js",
|
||||||
"src/layers/streetLevel/selection.js",
|
"src/layers/streetLevel/selection.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
"src/layers/streetLevel/viewer.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
"src/layers/streetLevel/source.js",
|
||||||
"server/providers/mapillary.js",
|
"server/providers/mapillary.js",
|
||||||
"server/providers/mapillary/tiles.js",
|
"server/providers/mapillary/tiles.js",
|
||||||
"server/providers/mapillary/trim.js",
|
"server/providers/mapillary/trim.js",
|
||||||
@@ -892,15 +890,15 @@
|
|||||||
"scripts/fixtures/street-level/mapillaryGraph.mjs",
|
"scripts/fixtures/street-level/mapillaryGraph.mjs",
|
||||||
"src/layers/streetLevel/filter.test.mjs",
|
"src/layers/streetLevel/filter.test.mjs",
|
||||||
"src/layers/streetLevel/view.test.mjs",
|
"src/layers/streetLevel/view.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/mapillary.test.mjs",
|
"src/layers/streetLevel/mapillary.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
|
"src/layers/streetLevel/decode.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
|
"src/layers/streetLevel/coverage.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
"src/layers/streetLevel/coverageFixture.mjs",
|
||||||
"src/layers/streetLevel/cameraFraming.test.mjs",
|
"src/layers/streetLevel/cameraFraming.test.mjs",
|
||||||
"src/ui/streetLevelControls.test.mjs",
|
"src/ui/streetLevelControls.test.mjs",
|
||||||
"src/layers/streetLevel/marker.test.mjs",
|
"src/layers/streetLevel/marker.test.mjs",
|
||||||
"src/layers/streetLevel/viewerHost.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/selection.test.mjs",
|
||||||
"src/layers/streetLevel/index.test.mjs",
|
"src/layers/streetLevel/index.test.mjs",
|
||||||
"scripts/qa-overpass-offload.mjs",
|
"scripts/qa-overpass-offload.mjs",
|
||||||
|
|||||||
@@ -1190,23 +1190,21 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
|
||||||
"src/layers/streetLevel/cameraFraming.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/view.js",
|
"src/layers/streetLevel/view.js",
|
||||||
"src/layers/streetLevel/filter.js",
|
"src/layers/streetLevel/filter.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
"src/layers/streetLevel/coverage.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
"src/layers/streetLevel/sequences.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
|
||||||
"src/layers/streetLevel/glyphs.js",
|
"src/layers/streetLevel/glyphs.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
"src/layers/streetLevel/decode.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
"src/layers/streetLevel/coverageFixture.mjs",
|
||||||
"src/layers/streetLevel/tileMath.js",
|
"src/layers/streetLevel/tileMath.js",
|
||||||
"src/layers/streetLevel/selection.js",
|
"src/layers/streetLevel/selection.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
"src/layers/streetLevel/viewer.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
"src/layers/streetLevel/source.js",
|
||||||
"scripts/qa-street-level.mjs"
|
"scripts/qa-street-level.mjs"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||||
@@ -2130,23 +2128,21 @@
|
|||||||
"src/ui/streetLevelPresentation.js",
|
"src/ui/streetLevelPresentation.js",
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
|
||||||
"src/layers/streetLevel/cameraFraming.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/view.js",
|
"src/layers/streetLevel/view.js",
|
||||||
"src/layers/streetLevel/filter.js",
|
"src/layers/streetLevel/filter.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
"src/layers/streetLevel/coverage.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
"src/layers/streetLevel/sequences.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
|
||||||
"src/layers/streetLevel/glyphs.js",
|
"src/layers/streetLevel/glyphs.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
"src/layers/streetLevel/decode.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
"src/layers/streetLevel/coverageFixture.mjs",
|
||||||
"src/layers/streetLevel/tileMath.js",
|
"src/layers/streetLevel/tileMath.js",
|
||||||
"src/layers/streetLevel/selection.js",
|
"src/layers/streetLevel/selection.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
"src/layers/streetLevel/viewer.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
"src/layers/streetLevel/source.js",
|
||||||
"src/app/layers/streetLevel.js",
|
"src/app/layers/streetLevel.js",
|
||||||
"src/layers/alpr/tileRecords.js",
|
"src/layers/alpr/tileRecords.js",
|
||||||
"src/sources/capability.js",
|
"src/sources/capability.js",
|
||||||
@@ -2645,23 +2641,21 @@
|
|||||||
"src/maps/imageryHost.js",
|
"src/maps/imageryHost.js",
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
|
||||||
"src/layers/streetLevel/cameraFraming.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/view.js",
|
"src/layers/streetLevel/view.js",
|
||||||
"src/layers/streetLevel/filter.js",
|
"src/layers/streetLevel/filter.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
"src/layers/streetLevel/coverage.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
"src/layers/streetLevel/sequences.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
|
||||||
"src/layers/streetLevel/glyphs.js",
|
"src/layers/streetLevel/glyphs.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
"src/layers/streetLevel/decode.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
"src/layers/streetLevel/coverageFixture.mjs",
|
||||||
"src/layers/streetLevel/tileMath.js",
|
"src/layers/streetLevel/tileMath.js",
|
||||||
"src/layers/streetLevel/selection.js",
|
"src/layers/streetLevel/selection.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
"src/layers/streetLevel/viewer.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
"src/layers/streetLevel/source.js",
|
||||||
"src/app/layers/streetLevel.js",
|
"src/app/layers/streetLevel.js",
|
||||||
"src/layers/alpr/tileRecords.js",
|
"src/layers/alpr/tileRecords.js",
|
||||||
"src/sources/capability.js",
|
"src/sources/capability.js",
|
||||||
|
|||||||
+5
-153
@@ -12,7 +12,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
import { tileBounds } from '../src/layers/streetLevel/tileMath.js';
|
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 {
|
import {
|
||||||
hookRenderErrors,
|
hookRenderErrors,
|
||||||
readRenderErrors,
|
readRenderErrors,
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
} from './fixtures/street-level/mapillaryGraph.mjs';
|
} from './fixtures/street-level/mapillaryGraph.mjs';
|
||||||
|
|
||||||
const VIEWPORT = { width: 1440, height: 900 };
|
const VIEWPORT = { width: 1440, height: 900 };
|
||||||
const PHONE = { width: 390, height: 844 };
|
|
||||||
/** Over downtown Sacramento, above the fixture photo line, looking down. */
|
/** 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 PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 };
|
||||||
const DAY_MS = 86_400_000;
|
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.
|
* sequences, plus the photo sequence where PHOTO_LINE crosses the tile.
|
||||||
* @returns {Uint8Array} empty below z11, which the layer never asks for
|
* @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);
|
if (z < 11) return new Uint8Array(0);
|
||||||
const { west, east, south, north } = tileBounds(x, y, z);
|
const { west, east, south, north } = tileBounds(x, y, z);
|
||||||
const at = (t, lo, hi) => lo + (hi - lo) * t;
|
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)',
|
'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 () => {
|
await expandPanel(page);
|
||||||
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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!configured) {
|
if (!configured) {
|
||||||
await step('keyless server: KEY REQUIRED gates the controls', () =>
|
await step('keyless server: KEY REQUIRED gates the controls', () =>
|
||||||
assertKeyRequired(page),
|
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(
|
await fixtureStep(
|
||||||
'a rejected key reads KEY REJECTED and clears when switched off',
|
'a rejected key reads KEY REJECTED and clears when switched off',
|
||||||
async () => {
|
async () => {
|
||||||
@@ -549,7 +449,6 @@ async function main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let imageId = null;
|
|
||||||
await park(page);
|
await park(page);
|
||||||
await waitForCoverage(page);
|
await waitForCoverage(page);
|
||||||
await step(
|
await step(
|
||||||
@@ -585,7 +484,6 @@ async function main() {
|
|||||||
);
|
);
|
||||||
const { street } = await getUI(page);
|
const { street } = await getUI(page);
|
||||||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||||||
imageId = street.imageId;
|
|
||||||
await page.waitForSelector('#sl-viewer .mapillary-dom', {
|
await page.waitForSelector('#sl-viewer .mapillary-dom', {
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
@@ -606,8 +504,8 @@ async function main() {
|
|||||||
assert.match(view.link, /mapillary\.com\/app\/\?pKey=/);
|
assert.match(view.link, /mapillary\.com\/app\/\?pKey=/);
|
||||||
if (fixtures) {
|
if (fixtures) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
fixture.images.some((image) => image.id === imageId),
|
fixture.images.some((image) => image.id === street.imageId),
|
||||||
`fixture photo ${imageId}`,
|
`fixture photo ${street.imageId}`,
|
||||||
);
|
);
|
||||||
assert.equal(street.sequenceId, PHOTO_SEQUENCE_ID);
|
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(
|
await fixtureStep(
|
||||||
'keyless server (second page): KEY REQUIRED gates the controls',
|
'keyless server (second page): KEY REQUIRED gates the controls',
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ const FETCH_TIMEOUT_MS = 60_000;
|
|||||||
export const TILE_UPSTREAM_CONCURRENCY = 6;
|
export const TILE_UPSTREAM_CONCURRENCY = 6;
|
||||||
/** Tiles change only when new imagery is processed. */
|
/** Tiles change only when new imagery is processed. */
|
||||||
export const TILE_TTL_MS = 24 * 60 * 60 * 1000;
|
export const TILE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024;
|
/** Trimmed tiles are ~80 KB: a dozen 9-tile views, well under 100 MB. */
|
||||||
/** Per-entry cost on top of its bytes, so empty tiles still count. */
|
export const TILE_MEMORY_MAX_TILES = 120;
|
||||||
const ENTRY_OVERHEAD_BYTES = 1024;
|
|
||||||
|
|
||||||
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
|
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
|
||||||
export function mapillaryToken() {
|
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();
|
const memory = new Map();
|
||||||
let memoryBytes = 0;
|
|
||||||
/**
|
/**
|
||||||
* Upstream fetches shared per tile, with the requests still waiting on each.
|
* Upstream fetches shared per tile, with the requests still waiting on each.
|
||||||
* @type {Map<string, {promise: Promise<Buffer>, controller: AbortController, waiters: number}>}
|
* @type {Map<string, {promise: Promise<Buffer>, controller: AbortController, waiters: number}>}
|
||||||
*/
|
*/
|
||||||
const inFlight = new Map();
|
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()) {
|
function remember(key, bytes, at = Date.now()) {
|
||||||
// One huge (untrimmed) tile must not flush everything else.
|
memory.delete(key);
|
||||||
if (cost(bytes) > TILE_MEMORY_BUDGET_BYTES / 2) return;
|
|
||||||
forget(key);
|
|
||||||
memory.set(key, { bytes, at });
|
memory.set(key, { bytes, at });
|
||||||
memoryBytes += cost(bytes);
|
for (const oldest of memory.keys()) {
|
||||||
for (const [oldest] of memory) {
|
if (memory.size <= TILE_MEMORY_MAX_TILES) break;
|
||||||
if (memoryBytes <= TILE_MEMORY_BUDGET_BYTES) break;
|
memory.delete(oldest);
|
||||||
forget(oldest);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function recall(key) {
|
function recall(key) {
|
||||||
const hit = forget(key);
|
const hit = memory.get(key);
|
||||||
if (!hit || Date.now() - hit.at > TILE_TTL_MS) return null;
|
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
|
remember(key, hit.bytes, hit.at); // most recently used again
|
||||||
return hit.bytes;
|
return hit.bytes;
|
||||||
}
|
}
|
||||||
@@ -225,11 +215,5 @@ export async function fetchTile(address, { signal } = {}) {
|
|||||||
/** Test seam: forget every cached and in-flight tile. */
|
/** Test seam: forget every cached and in-flight tile. */
|
||||||
export function _resetTileCacheForTest() {
|
export function _resetTileCacheForTest() {
|
||||||
memory.clear();
|
memory.clear();
|
||||||
memoryBytes = 0;
|
|
||||||
inFlight.clear();
|
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);
|
}, null);
|
||||||
return dropped ? Buffer.from(writer.finish()) : bytes;
|
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 { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||||
import { createWindLayer } from '../layers/wind/index.js';
|
import { createWindLayer } from '../layers/wind/index.js';
|
||||||
import { createLayerCatalog } from './catalog.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 { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||||
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
||||||
import { createApplicationFlights } from './layers/flights.js';
|
import { createApplicationFlights } from './layers/flights.js';
|
||||||
@@ -57,7 +56,7 @@ const SOURCE_METHODS = Object.freeze({
|
|||||||
earthquakes: ['getSnapshot'],
|
earthquakes: ['getSnapshot'],
|
||||||
'fire-perimeters': ['getSnapshot'],
|
'fire-perimeters': ['getSnapshot'],
|
||||||
cables: ['fetch'],
|
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 test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createCameraFraming } from './cameraFraming.js';
|
import { createCameraFraming } from './cameraFraming.js';
|
||||||
|
import { fakeCesiumViewer } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
|
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
|
||||||
const heightOf = (cartesian) =>
|
const heightOf = (cartesian) =>
|
||||||
Cesium.Cartographic.fromCartesian(cartesian).height;
|
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
|
* 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`.
|
* under the photo load) and whose globe terrain is `globe`.
|
||||||
*/
|
*/
|
||||||
function setup({ sampled, globe = null, altitude = 149 }) {
|
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 = {
|
const state = {
|
||||||
services: {},
|
services: {},
|
||||||
street: {
|
street: {
|
||||||
@@ -51,19 +26,13 @@ function setup({ sampled, globe = null, altitude = 149 }) {
|
|||||||
bearing: 105,
|
bearing: 105,
|
||||||
altitude,
|
altitude,
|
||||||
},
|
},
|
||||||
viewer: {
|
viewer,
|
||||||
scene: {
|
|
||||||
sampleHeightSupported: true,
|
|
||||||
sampleHeight: () => sampled,
|
|
||||||
globe: { getHeight: () => globe },
|
|
||||||
},
|
|
||||||
camera: flightCamera(flights),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
framing: createCameraFraming({ state }),
|
framing: createCameraFraming({ state }),
|
||||||
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', () => {
|
test('cancel stops the framing flight while it is still ours', () => {
|
||||||
const { framing, state } = setup({ sampled: 121 });
|
const { framing, viewer } = setup({ sampled: 121 });
|
||||||
const { camera } = state.viewer;
|
|
||||||
framing.frame(framing.begin());
|
framing.frame(framing.begin());
|
||||||
framing.cancel();
|
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();
|
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.
|
// Re-framing (the next photo) supersedes the first flight, not the claim.
|
||||||
framing.frame(framing.begin());
|
framing.frame(framing.begin());
|
||||||
framing.frame(framing.begin());
|
framing.frame(framing.begin());
|
||||||
framing.cancel();
|
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', () => {
|
test('cancel leaves a landed flight and a newer navigation flight alone', () => {
|
||||||
const { framing, state, flights } = setup({ sampled: 121 });
|
const { framing, viewer, flights } = setup({ sampled: 121 });
|
||||||
const { camera } = state.viewer;
|
|
||||||
framing.frame(framing.begin());
|
framing.frame(framing.begin());
|
||||||
camera.land();
|
viewer.flights.land();
|
||||||
framing.cancel();
|
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());
|
framing.frame(framing.begin());
|
||||||
// A search result flies the globe elsewhere; Cesium cancels ours first.
|
// A search result flies the globe elsewhere; Cesium cancels ours first.
|
||||||
camera.flyToBoundingSphere(null, {});
|
viewer.camera.flyToBoundingSphere(null, {});
|
||||||
assert.equal(flights.length, 3);
|
assert.equal(flights.length, 3);
|
||||||
framing.cancel();
|
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 * as Cesium from 'cesium';
|
||||||
import { decodeCoverageTile } from './decode.js';
|
import { decodeCoverageTile } from './decode.js';
|
||||||
import { passesImageryFilter, resolveFilter } from '../../filter.js';
|
import { passesImageryFilter, resolveFilter } from './filter.js';
|
||||||
import { isActive } from '../../state.js';
|
import { isActive } from './state.js';
|
||||||
import { groundUnderCamera, viewFocus, visibleBbox } from '../../view.js';
|
import { groundUnderCamera, viewFocus, visibleBbox } from './view.js';
|
||||||
import { coverageZoomForHeight, tilesForBbox } from '../../tileMath.js';
|
import { coverageZoomForHeight, tilesForBbox } from './tileMath.js';
|
||||||
import {
|
import {
|
||||||
COLORS,
|
COLORS,
|
||||||
COVERAGE_LINE_WIDTH_PX,
|
COVERAGE_LINE_WIDTH_PX,
|
||||||
+3
-3
@@ -2,11 +2,11 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createCoverage, ZOOM_IN_HINT } from './coverage.js';
|
import { createCoverage, ZOOM_IN_HINT } from './coverage.js';
|
||||||
import { freshCoverage } from '../../state.js';
|
import { freshCoverage } from './state.js';
|
||||||
import { encodeCoverageTile } from './coverageFixture.mjs';
|
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 { 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;
|
const RAD = Math.PI / 180;
|
||||||
// Node has no WebGL context to report line-width limits; a browser's
|
// 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 { PbfReader } from 'pbf';
|
||||||
import { VectorTile } from '@mapbox/vector-tile';
|
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
|
* 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 svg(size, body) {
|
||||||
function cachedGlyph(key, size, draw) {
|
const markup = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${body}</svg>`;
|
||||||
if (_cache.has(key)) return _cache.get(key);
|
return `data:image/svg+xml;utf8,${encodeURIComponent(markup)}`;
|
||||||
const element = document.createElement('canvas');
|
|
||||||
element.width = size;
|
|
||||||
element.height = size;
|
|
||||||
draw(element.getContext('2d'), size / 2);
|
|
||||||
_cache.set(key, element);
|
|
||||||
return element;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A filled wedge from the centre, pointing "up", `halfAngle` radians wide. */
|
/** A filled wedge from the centre, pointing "up", `halfAngle` radians wide. */
|
||||||
function wedge(ctx, c, radius, halfAngle, color, alpha) {
|
function wedge(size, radius, halfAngle, color, opacity) {
|
||||||
ctx.beginPath();
|
const c = size / 2;
|
||||||
ctx.moveTo(c, c);
|
const dx = px(radius * Math.sin(halfAngle));
|
||||||
ctx.arc(c, c, radius, -Math.PI / 2 - halfAngle, -Math.PI / 2 + halfAngle);
|
const dy = px(radius * Math.cos(halfAngle));
|
||||||
ctx.closePath();
|
const r = px(radius);
|
||||||
ctx.fillStyle = color;
|
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}"/>`;
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.fill();
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ring(ctx, c, radius, color, width, alpha) {
|
function ring(size, radius, color, width, opacity) {
|
||||||
ctx.beginPath();
|
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="none" stroke="${color}" stroke-width="${px(width)}" stroke-opacity="${opacity}"/>`;
|
||||||
ctx.arc(c, c, radius, 0, Math.PI * 2);
|
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.lineWidth = width;
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dot(ctx, c, radius, color, outline, width) {
|
function dot(size, radius, color, outline, width) {
|
||||||
ctx.beginPath();
|
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="${color}" stroke="${outline}" stroke-width="${px(width)}"/>`;
|
||||||
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. */
|
||||||
@@ -51,25 +30,28 @@ export function imageConeGlyph({
|
|||||||
color = '#e8eaed',
|
color = '#e8eaed',
|
||||||
pano = false,
|
pano = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return cachedGlyph(`cone:${size}:${color}:${pano}`, size, (ctx, c) => {
|
const shape = pano
|
||||||
if (pano) ring(ctx, c, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5);
|
? ring(size, 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);
|
: wedge(size, size * 0.46, 0.62, color, 0.42);
|
||||||
dot(
|
return svg(
|
||||||
ctx,
|
size,
|
||||||
c,
|
shape +
|
||||||
size * 0.16,
|
dot(
|
||||||
color,
|
size,
|
||||||
'rgba(10,10,15,0.85)',
|
size * 0.16,
|
||||||
Math.max(1, size * 0.05),
|
color,
|
||||||
);
|
'rgba(10,10,15,0.85)',
|
||||||
});
|
Math.max(1, size * 0.05),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Marker for the image the viewer currently shows. */
|
/** Marker for the image the viewer currently shows. */
|
||||||
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
|
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
|
||||||
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
|
return svg(
|
||||||
wedge(ctx, c, size * 0.48, 0.55, color, 0.55);
|
size,
|
||||||
dot(ctx, c, size * 0.2, color, '#0a0a0f', 2);
|
wedge(size, size * 0.48, 0.55, color, 0.55) +
|
||||||
ring(ctx, c, size * 0.3, color, 1.5, 0.8);
|
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 { createState } from './state.js';
|
||||||
import { createMarker } from './marker.js';
|
import { createMarker } from './marker.js';
|
||||||
import { createCameraFraming } from './cameraFraming.js';
|
import { createCameraFraming } from './cameraFraming.js';
|
||||||
import { createCredit } from './credits.js';
|
|
||||||
import { createSelection } from './selection.js';
|
import { createSelection } from './selection.js';
|
||||||
import { createViewerHost } from './viewerHost.js';
|
import { createViewerHost } from './viewerHost.js';
|
||||||
import { normalizeFilter, sameFilter } from './filter.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 {
|
import {
|
||||||
|
keyStatusLabel,
|
||||||
MAPILLARY_CREDIT_HTML,
|
MAPILLARY_CREDIT_HTML,
|
||||||
MAPILLARY_KEY_ID,
|
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';
|
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
|
* The Street Level layer: Mapillary coverage lines at street zoom, a selected
|
||||||
* sequence's image cones, and the photo viewer in the panel.
|
* sequence's image cones, and the photo viewer in the panel.
|
||||||
* @param {{source: object, services?: object, photoViewer?: object}} options
|
* @param {{source: object, services?: object, photoViewer?: object}} options
|
||||||
* `source` is providers/mapillary/source.js or a stand-in; `photoViewer`
|
* `source` is source.js or a stand-in; `photoViewer` stands in for the
|
||||||
* stands in for the MapillaryJS adapter (providers/mapillary/viewer.js).
|
* MapillaryJS adapter (viewer.js).
|
||||||
*/
|
*/
|
||||||
export function createStreetLevelLayer({
|
export function createStreetLevelLayer({
|
||||||
source,
|
source,
|
||||||
@@ -44,7 +45,6 @@ export function createStreetLevelLayer({
|
|||||||
const state = createState({ services });
|
const state = createState({ services });
|
||||||
const parts = {};
|
const parts = {};
|
||||||
const context = { state, parts };
|
const context = { state, parts };
|
||||||
const credit = createCredit(MAPILLARY_CREDIT_HTML);
|
|
||||||
parts.marker = createMarker(context);
|
parts.marker = createMarker(context);
|
||||||
parts.framing = createCameraFraming(context);
|
parts.framing = createCameraFraming(context);
|
||||||
parts.coverage = createCoverage({ state, source });
|
parts.coverage = createCoverage({ state, source });
|
||||||
@@ -97,12 +97,25 @@ export function createStreetLevelLayer({
|
|||||||
return keyCheck;
|
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() {
|
function startDrawing() {
|
||||||
const viewer = state.viewer;
|
const viewer = state.viewer;
|
||||||
if (!viewer) return;
|
if (!viewer) return;
|
||||||
parts.sequences.setVisible(true);
|
parts.sequences.setVisible(true);
|
||||||
parts.coverage.attach(viewer);
|
parts.coverage.attach(viewer);
|
||||||
credit.show(viewer);
|
showCredit(viewer);
|
||||||
// Without a known key, a switch-on asks again: the server may be back,
|
// Without a known key, a switch-on asks again: the server may be back,
|
||||||
// or have a key now.
|
// or have a key now.
|
||||||
if (['status', 'no-key'].includes(state.coverage.blocked)) checkKey();
|
if (['status', 'no-key'].includes(state.coverage.blocked)) checkKey();
|
||||||
@@ -116,7 +129,7 @@ export function createStreetLevelLayer({
|
|||||||
parts.coverage.unblock();
|
parts.coverage.unblock();
|
||||||
parts.sequences.clearSelection();
|
parts.sequences.clearSelection();
|
||||||
parts.sequences.setVisible(false);
|
parts.sequences.setVisible(false);
|
||||||
credit.hide(state.viewer);
|
hideCredit(state.viewer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The share link's `mapillary` switch: off, the layer draws nothing. */
|
/** 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 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 './policy.js';
|
||||||
import {
|
import {
|
||||||
|
fakeCesiumViewer,
|
||||||
fakeMapillarySource,
|
fakeMapillarySource,
|
||||||
fakePhotoViewer,
|
fakePhotoViewer,
|
||||||
rayCamera,
|
|
||||||
} from '../../testSupport/streetLevelFakes.mjs';
|
} 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
|
* A layer over `source` and `photoViewer`, initialised on a stand-in viewer
|
||||||
* and (unless `enable` is false) enabled.
|
* and (unless `enable` is false) enabled.
|
||||||
@@ -93,18 +23,15 @@ async function startLayer(
|
|||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const saved = globalThis.document;
|
const saved = globalThis.document;
|
||||||
// A pose moves the position marker, whose glyph is drawn on a canvas.
|
// The click handler listens for Esc on the document.
|
||||||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
globalThis.document = new EventTarget();
|
||||||
globalThis.document = Object.assign(new EventTarget(), {
|
|
||||||
createElement: () => ({ getContext: () => drawing }),
|
|
||||||
});
|
|
||||||
// Skip Cesium's one-time terrain table download for draped lines.
|
// Skip Cesium's one-time terrain table download for draped lines.
|
||||||
t.mock.method(
|
t.mock.method(
|
||||||
Cesium.GroundPolylinePrimitive,
|
Cesium.GroundPolylinePrimitive,
|
||||||
'initializeTerrainHeights',
|
'initializeTerrainHeights',
|
||||||
async () => {},
|
async () => {},
|
||||||
);
|
);
|
||||||
const viewer = fakeViewer({ view });
|
const viewer = fakeCesiumViewer({ view });
|
||||||
const layer = createStreetLevelLayer({ source, photoViewer });
|
const layer = createStreetLevelLayer({ source, photoViewer });
|
||||||
layer.init(viewer);
|
layer.init(viewer);
|
||||||
if (enable) layer.enable(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 * as Cesium from 'cesium';
|
||||||
import { createMarker } from './marker.js';
|
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. */
|
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
|
||||||
function setup() {
|
function setup() {
|
||||||
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
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 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. */
|
/** Pick id of the viewer position marker. */
|
||||||
export const POSITION_PICK_ID = 'sl:pos';
|
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({
|
export const COLORS = Object.freeze({
|
||||||
|
coverage: '#05cb63',
|
||||||
selected: '#00d4ff',
|
selected: '#00d4ff',
|
||||||
position: '#ffb300',
|
position: '#ffb300',
|
||||||
});
|
});
|
||||||
@@ -19,6 +29,44 @@ export const MAX_SINCE_DAYS = 36_500;
|
|||||||
/** Filter the layer starts with: all imagery, any date. */
|
/** Filter the layer starts with: all imagery, any date. */
|
||||||
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
|
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. */
|
/** The key gate's label for the pill and the layer list, or null when the key is fine. */
|
||||||
export function keyStatusLabel({ keyRequired, keyRejected }) {
|
export function keyStatusLabel({ keyRequired, keyRejected }) {
|
||||||
if (keyRejected) return 'KEY REJECTED';
|
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 * as Cesium from 'cesium';
|
||||||
import { POSITION_PICK_ID, STREET_LEVEL_LAYER_ID } from './policy.js';
|
import {
|
||||||
import { PICK_PREFIX } from './providers/mapillary/policy.js';
|
PICK_PREFIX,
|
||||||
import { sequenceIdFromPick } from './providers/mapillary/coverage.js';
|
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 }) {
|
export function createSelection({ state, parts }) {
|
||||||
const { picking, input } = state.services;
|
const { picking, input } = state.services;
|
||||||
|
|
||||||
@@ -51,67 +54,6 @@ export function createSelection({ state, parts }) {
|
|||||||
parts.sequences.clearSelection();
|
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) {
|
function install(viewer) {
|
||||||
if (state.clickHandler) return;
|
if (state.clickHandler) return;
|
||||||
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
|
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||||
@@ -121,11 +63,6 @@ export function createSelection({ state, parts }) {
|
|||||||
onClick,
|
onClick,
|
||||||
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
||||||
);
|
);
|
||||||
state.clickHandler.setInputAction(
|
|
||||||
onMove,
|
|
||||||
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
|
|
||||||
);
|
|
||||||
watchHover(viewer);
|
|
||||||
document.addEventListener('keydown', onKeyDown);
|
document.addEventListener('keydown', onKeyDown);
|
||||||
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick);
|
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick);
|
||||||
}
|
}
|
||||||
@@ -133,16 +70,7 @@ export function createSelection({ state, parts }) {
|
|||||||
function uninstall() {
|
function uninstall() {
|
||||||
// Nothing to undo for a layer that was never enabled.
|
// Nothing to undo for a layer that was never enabled.
|
||||||
if (!state.clickHandler) return;
|
if (!state.clickHandler) return;
|
||||||
removeHoverWatchers?.();
|
if (!state.clickHandler.isDestroyed()) state.clickHandler.destroy();
|
||||||
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();
|
|
||||||
state.clickHandler = null;
|
state.clickHandler = null;
|
||||||
document.removeEventListener('keydown', onKeyDown);
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
|
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
|
||||||
|
|||||||
@@ -3,17 +3,9 @@ import test from 'node:test';
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createSelection } from './selection.js';
|
import { createSelection } from './selection.js';
|
||||||
|
|
||||||
/**
|
/** The selection installed on a stand-in viewer, with what its clicks reach. */
|
||||||
* The selection installed on a stand-in viewer. Animation frames queue until
|
|
||||||
* `frame()` runs them.
|
|
||||||
*/
|
|
||||||
function harness({ selected = false } = {}) {
|
function harness({ selected = false } = {}) {
|
||||||
const saved = {
|
const saved = globalThis.document;
|
||||||
document: globalThis.document,
|
|
||||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
|
||||||
};
|
|
||||||
const frames = [];
|
|
||||||
globalThis.requestAnimationFrame = (task) => frames.push(task);
|
|
||||||
globalThis.document = new EventTarget();
|
globalThis.document = new EventTarget();
|
||||||
// The document's keydown listeners, so a test can hand them any target.
|
// The document's keydown listeners, so a test can hand them any target.
|
||||||
const keyListeners = new Set();
|
const keyListeners = new Set();
|
||||||
@@ -31,26 +23,17 @@ function harness({ selected = false } = {}) {
|
|||||||
if (type === 'keydown') keyListeners.delete(listener);
|
if (type === 'keydown') keyListeners.delete(listener);
|
||||||
removeListener(type, listener, options);
|
removeListener(type, listener, options);
|
||||||
};
|
};
|
||||||
const sequences = { selected, cleared: 0 };
|
const sequences = { selected, cleared: 0, selects: [] };
|
||||||
const picks = [];
|
const opened = [];
|
||||||
/** What the pointer is over: a line this layer owns, or nothing. */
|
/** What the pointer is over: a pick id, or nothing. */
|
||||||
const scene = { under: null };
|
const scene = { under: null };
|
||||||
const canvas = Object.assign(new EventTarget(), {
|
const canvas = Object.assign(new EventTarget(), {
|
||||||
style: {},
|
|
||||||
// Keep Cesium's handler on the canvas; there is no real document here.
|
// Keep Cesium's handler on the canvas; there is no real document here.
|
||||||
disableRootEvents: true,
|
disableRootEvents: true,
|
||||||
onwheel: null,
|
onwheel: null,
|
||||||
});
|
});
|
||||||
const camera = { moveEnd: new Cesium.Event() };
|
|
||||||
const viewer = {
|
const viewer = {
|
||||||
camera,
|
scene: { canvas, pick: () => scene.under && { id: scene.under } },
|
||||||
scene: {
|
|
||||||
canvas,
|
|
||||||
pick(position) {
|
|
||||||
picks.push(position);
|
|
||||||
return scene.under && { id: scene.under };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
const state = {
|
const state = {
|
||||||
viewer,
|
viewer,
|
||||||
@@ -68,20 +51,24 @@ function harness({ selected = false } = {}) {
|
|||||||
state,
|
state,
|
||||||
parts: {
|
parts: {
|
||||||
sequences: {
|
sequences: {
|
||||||
|
select: (id) => sequences.selects.push(id),
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
sequences.cleared++;
|
sequences.cleared++;
|
||||||
sequences.selected = false;
|
sequences.selected = false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
openImage: (id) => opened.push(id),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
selection.install(viewer);
|
selection.install(viewer);
|
||||||
const action = (type) =>
|
const action = (type) =>
|
||||||
state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]);
|
state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]);
|
||||||
const onMove = action('MOUSE_MOVE');
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
sequences,
|
sequences,
|
||||||
|
opened,
|
||||||
|
scene,
|
||||||
|
click: () => action('LEFT_CLICK')({ position: { x: 10, y: 10 } }),
|
||||||
/** A keydown reaching the document from `target`; the event as handled. */
|
/** A keydown reaching the document from `target`; the event as handled. */
|
||||||
key(key, target, { defaultPrevented = false } = {}) {
|
key(key, target, { defaultPrevented = false } = {}) {
|
||||||
const event = {
|
const event = {
|
||||||
@@ -95,94 +82,32 @@ function harness({ selected = false } = {}) {
|
|||||||
for (const listener of [...keyListeners]) listener(event);
|
for (const listener of [...keyListeners]) listener(event);
|
||||||
return 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() {
|
restore() {
|
||||||
selection.uninstall();
|
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();
|
const h = harness();
|
||||||
try {
|
try {
|
||||||
h.scene.under = 'mly:seq:1';
|
h.scene.under = 'mly:seq:abc';
|
||||||
for (let x = 0; x < 5; x++) h.move(x);
|
h.click();
|
||||||
h.frame();
|
assert.deepEqual(h.sequences.selects, ['abc']);
|
||||||
assert.deepEqual(
|
h.scene.under = 'mly:img:42';
|
||||||
h.picks.map(({ x }) => x),
|
h.click();
|
||||||
[4],
|
assert.deepEqual(h.opened, ['42']);
|
||||||
'one pick for the burst',
|
for (const under of [null, 'cctv:7', 'sl:pos']) {
|
||||||
);
|
h.scene.under = under;
|
||||||
assert.equal(h.canvas.style.cursor, 'pointer');
|
h.click();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
assert.equal(h.picks.length, 0, 'left, right and middle drags');
|
assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]);
|
||||||
// Released with Shift held: Cesium's plain LEFT_UP never fires.
|
// Mapillary switched off under a layer that is on: lines are not drawn.
|
||||||
h.buttons('pointerup', 0);
|
h.state.providerOn = false;
|
||||||
h.move(5);
|
h.scene.under = 'mly:seq:abc';
|
||||||
h.frame();
|
h.click();
|
||||||
assert.equal(h.picks.length, 1, 'hover picks again');
|
assert.deepEqual(h.sequences.selects, ['abc']);
|
||||||
} 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');
|
|
||||||
} finally {
|
} finally {
|
||||||
h.restore();
|
h.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { imageConeGlyph } from '../../glyphs.js';
|
import { imageConeGlyph } from './glyphs.js';
|
||||||
import { passesImageryFilter, resolveFilter } from '../../filter.js';
|
import { passesImageryFilter, resolveFilter } from './filter.js';
|
||||||
import { createHorizonCull, metresBetween } from '../../view.js';
|
import { createHorizonCull, metresBetween } from './view.js';
|
||||||
import {
|
import {
|
||||||
COLORS,
|
COLORS,
|
||||||
IMAGE_CONE_MIN_SPACING_M,
|
IMAGE_CONE_MIN_SPACING_M,
|
||||||
-7
@@ -3,13 +3,6 @@ import test from 'node:test';
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createSequences } from './sequences.js';
|
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));
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
/** A billboard collection that remembers the cones on the globe. */
|
/** 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 };
|
return { format: 'module', shortCircuit: true, source };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { createMapillaryViewer } =
|
const { createMapillaryViewer } = await import('./viewer.js');
|
||||||
await import('./providers/mapillary/viewer.js');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A viewer host over `adapter`. `marker` records where the position marker
|
* 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 { createCycloneSource } from '../layers/cyclones/source.js';
|
||||||
import { createWindSource } from '../layers/wind/source.js';
|
import { createWindSource } from '../layers/wind/source.js';
|
||||||
import { createFirmsSource } from '../layers/firms/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';
|
import { createReferenceSources } from '../sources/reference.js';
|
||||||
export { createReferenceSources as createStandaloneReferenceSources } 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';
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
const RAD = Math.PI / 180;
|
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
|
* A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and
|
||||||
* `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets.
|
* `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets.
|
||||||
|
|||||||
@@ -5,19 +5,15 @@ import {
|
|||||||
mapillaryProxy,
|
mapillaryProxy,
|
||||||
TILE_ROUTE_MAX_PER_MIN,
|
TILE_ROUTE_MAX_PER_MIN,
|
||||||
} from 'gods-eye-view/server/providers/mapillary';
|
} from 'gods-eye-view/server/providers/mapillary';
|
||||||
import {
|
import { stripTileLayers } from '../../server/providers/mapillary/trim.js';
|
||||||
listTileLayers,
|
|
||||||
stripTileLayers,
|
|
||||||
} from '../../server/providers/mapillary/trim.js';
|
|
||||||
import {
|
import {
|
||||||
TILE_MAX_BYTES,
|
TILE_MAX_BYTES,
|
||||||
TILE_MEMORY_BUDGET_BYTES,
|
TILE_MEMORY_MAX_TILES,
|
||||||
TILE_UPSTREAM_CONCURRENCY,
|
TILE_UPSTREAM_CONCURRENCY,
|
||||||
TILE_TTL_MS,
|
TILE_TTL_MS,
|
||||||
fetchTile,
|
fetchTile,
|
||||||
parseTilePath,
|
parseTilePath,
|
||||||
_resetTileCacheForTest,
|
_resetTileCacheForTest,
|
||||||
_tileMemoryForTest,
|
|
||||||
} from '../../server/providers/mapillary/tiles.js';
|
} from '../../server/providers/mapillary/tiles.js';
|
||||||
|
|
||||||
/** Build a minimal MVT: layers with a name, a version and one opaque feature. */
|
/** 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 () => {
|
test('the memory cache evicts the least recently used tile past its size', 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);
|
|
||||||
await withUpstream(
|
await withUpstream(
|
||||||
() => ok(big),
|
() => ok(),
|
||||||
async ({ calls, call }) => {
|
async ({ calls }) => {
|
||||||
const get = async (x) =>
|
const get = async (x) =>
|
||||||
(await call('/api/mapillary/tiles', `/coverage/14/${x}/20`)).headers[
|
(await fetchTile(parseTilePath(`/coverage/14/${x}/20`))).source;
|
||||||
'x-gev-cache'
|
for (let x = 0; x < TILE_MEMORY_MAX_TILES; x++) await get(x);
|
||||||
];
|
assert.equal(await get(0), 'memory', 'the first is used again');
|
||||||
for (const x of [20, 21, 22]) assert.equal(await get(x), 'upstream');
|
await get(TILE_MEMORY_MAX_TILES);
|
||||||
assert.equal(await get(20), 'memory', 'A is used again');
|
assert.equal(await get(0), 'memory', 'so it stays');
|
||||||
assert.equal(await get(23), 'upstream');
|
assert.equal(await get(1), 'upstream', 'the least recently used went');
|
||||||
assert.equal(_tileMemoryForTest().entries, 3);
|
assert.equal(calls.length, TILE_MEMORY_MAX_TILES + 2);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -578,7 +566,6 @@ test('dropping a layer keeps the others byte-for-byte', () => {
|
|||||||
{ name: 'overview' },
|
{ name: 'overview' },
|
||||||
]);
|
]);
|
||||||
const trimmed = stripTileLayers(bytes, ['image']);
|
const trimmed = stripTileLayers(bytes, ['image']);
|
||||||
assert.deepEqual(listTileLayers(trimmed), ['sequence', 'overview']);
|
|
||||||
assert.deepEqual(trimmed, tile([{ name: 'sequence' }, { name: 'overview' }]));
|
assert.deepEqual(trimmed, tile([{ name: 'sequence' }, { name: 'overview' }]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +1,14 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { fixtureTile } from '../../scripts/qa-street-level.mjs';
|
|
||||||
import {
|
import {
|
||||||
answerMapillaryRequest,
|
answerMapillaryRequest,
|
||||||
describeCall,
|
describeCall,
|
||||||
PHOTO_LINE,
|
|
||||||
PHOTO_SEQUENCE_ID,
|
PHOTO_SEQUENCE_ID,
|
||||||
photoImages,
|
photoImages,
|
||||||
THUMB_HOST,
|
THUMB_HOST,
|
||||||
} from '../../scripts/fixtures/street-level/mapillaryGraph.mjs';
|
} from '../../scripts/fixtures/street-level/mapillaryGraph.mjs';
|
||||||
import { redact } from '../../scripts/qa-browserEvidence.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) =>
|
const graph = (path, params) =>
|
||||||
`https://graph.mapillary.com/${path}?${new URLSearchParams({ access_token: 'MLY|0|qa-fixture', ...params })}`;
|
`https://graph.mapillary.com/${path}?${new URLSearchParams({ access_token: 'MLY|0|qa-fixture', ...params })}`;
|
||||||
const answer = (url, method = 'GET') => {
|
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', () => {
|
test('the Graph fixtures answer every call MapillaryJS makes to open a photo', () => {
|
||||||
const [first, second] = photoImages();
|
const [first, second] = photoImages();
|
||||||
const spatial = answer(
|
const spatial = answer(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from './globePanel.js';
|
} from './globePanel.js';
|
||||||
import { panelRuntime } from '../app/globePanelRuntime.js';
|
import { panelRuntime } from '../app/globePanelRuntime.js';
|
||||||
import { composeCatalog, coreTools } from './index.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`). */
|
/** Whether a CSP source list allows a URL (exact origin or `https://*.host`). */
|
||||||
function allows(sources, url) {
|
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
|
* Fills the Street Level panel body: filters and viewer. The panel chrome
|
||||||
* chrome itself belongs to the application shell.
|
* itself belongs to the application shell.
|
||||||
*/
|
*/
|
||||||
export class StreetLevelControls {
|
export class StreetLevelControls {
|
||||||
constructor({ root, layer, actions }) {
|
constructor({ root, layer, actions }) {
|
||||||
@@ -58,7 +58,6 @@ export class StreetLevelControls {
|
|||||||
errorText: byId('sl-error-text'),
|
errorText: byId('sl-error-text'),
|
||||||
sinceRange: byId('sl-since'),
|
sinceRange: byId('sl-since'),
|
||||||
sinceLabel: byId('sl-since-label'),
|
sinceLabel: byId('sl-since-label'),
|
||||||
legend: byId('sl-legend'),
|
|
||||||
viewerWrap: byId('sl-viewer-wrap'),
|
viewerWrap: byId('sl-viewer-wrap'),
|
||||||
viewerExpand: byId('sl-viewer-expand'),
|
viewerExpand: byId('sl-viewer-expand'),
|
||||||
viewerClose: byId('sl-viewer-close'),
|
viewerClose: byId('sl-viewer-close'),
|
||||||
@@ -376,7 +375,6 @@ export class StreetLevelControls {
|
|||||||
setAttr(el.sinceRange, 'aria-valuetext', view.since.label);
|
setAttr(el.sinceRange, 'aria-valuetext', view.since.label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setProp(el.legend, 'hidden', !view.legend);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderViewer(view) {
|
_renderViewer(view) {
|
||||||
|
|||||||
@@ -223,7 +223,6 @@ function panelDom({ fullscreen = true, popover = false } = {}) {
|
|||||||
add(controls, 'button', { dataset: { slPano: pano } });
|
add(controls, 'button', { dataset: { slPano: pano } });
|
||||||
add(controls, 'input', { id: 'sl-since' });
|
add(controls, 'input', { id: 'sl-since' });
|
||||||
add(controls, 'output', { id: 'sl-since-label' });
|
add(controls, 'output', { id: 'sl-since-label' });
|
||||||
add(root, 'ul', { id: 'sl-legend' });
|
|
||||||
add(root, 'div', { id: 'sl-coverage-meta' });
|
add(root, 'div', { id: 'sl-coverage-meta' });
|
||||||
return { document, root, wrap };
|
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,
|
dom.root.querySelector('#sl-coverage-meta').textContent,
|
||||||
/Mapillary is off in this view/,
|
/Mapillary is off in this view/,
|
||||||
);
|
);
|
||||||
assert.equal(
|
|
||||||
dom.root.querySelector('#sl-legend').hidden,
|
|
||||||
true,
|
|
||||||
'no legend',
|
|
||||||
);
|
|
||||||
status.click();
|
status.click();
|
||||||
await settle();
|
await settle();
|
||||||
assert.deepEqual(calls.setParams, [
|
assert.deepEqual(calls.setParams, [
|
||||||
|
|||||||
@@ -130,7 +130,6 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
|||||||
null,
|
null,
|
||||||
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
|
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
|
||||||
since: presentSince(filter.sinceDays, now),
|
since: presentSince(filter.sinceDays, now),
|
||||||
legend: state.providerOn !== false,
|
|
||||||
viewer: presentViewer(state),
|
viewer: presentViewer(state),
|
||||||
meta: presentMeta(state),
|
meta: presentMeta(state),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -118,7 +118,7 @@
|
|||||||
color: #ff8a80;
|
color: #ff8a80;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Filters and legend ──────────────────────────────────────────────────── */
|
/* ── Filters ──────────────────────────────────────────────────── */
|
||||||
.sl-filters {
|
.sl-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -196,42 +196,6 @@
|
|||||||
color: var(--text-primary);
|
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 ─────────────────────────────────────────────────────── */
|
/* ── Embedded viewer ─────────────────────────────────────────────────────── */
|
||||||
.sl-viewer-wrap {
|
.sl-viewer-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -124,10 +124,6 @@
|
|||||||
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
|
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</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 id="sl-coverage-meta" class="sl-meta"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user