From c4ecb11f75e3638e11efa6b776761b5e10e7391b Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 15:02:00 +0100 Subject: [PATCH] feat(street-level): KartaView's SINCE is its upload date, noted in the panel KartaView's server filters by upload date only, so SINCE goes by it throughout for KartaView: the lines (as before), the photo a click opens and the cones, the cut-off taken as its UTC day as KartaView's startDate (passesKartaViewFilter). Providers may declare sinceBasis: 'upload' (validated); while one is lit the panel shows "KartaView: by upload date" under the SINCE slider, read out with it. The gate checks the note, and step 13's picks get 60 s on a loaded machine. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++-- README.md | 2 +- docs/CURRENT-STATE.md | 16 ++++--- scripts/qa-street-level.mjs | 17 +++++-- src/layers/streetLevel/index.js | 1 + .../streetLevel/providers/kartaview/index.js | 11 +++-- .../streetLevel/providers/kartaview/policy.js | 29 ++++++++++-- .../providers/kartaview/policy.test.mjs | 39 ++++++++++++++++ .../providers/kartaview/provider.test.mjs | 46 ++++++++++++------- src/layers/streetLevel/registry.js | 9 ++++ src/layers/streetLevel/registry.test.mjs | 5 ++ src/layers/streetLevel/uiState.js | 14 ++++++ src/layers/streetLevel/uiState.test.mjs | 12 +++++ src/ui/streetLevelControls.js | 4 ++ src/ui/streetLevelControls.test.mjs | 22 +++++++++ src/ui/streetLevelPresentation.js | 1 + src/ui/styles/street-level.css | 11 +++++ src/ui/templates/layer-panels.html | 3 +- 18 files changed, 210 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cde9b6883..36c0f2bc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,9 +116,11 @@ cone pointing along the drive (KartaView's own compass headings are often 0 or far off). The IMAGERY (ALL / 360° / FLAT) and SINCE filters apply to KartaView as to Mapillary: its lines are filtered by KartaView's own - server (its filtered PNG tiles, as a mask over the JSON lines; SINCE goes - by upload date there), and the photo a click opens, and the cones, by - each photo's projection and capture date. + server (its filtered PNG tiles, as a mask over the JSON lines), and the + photo a click opens, and the cones, by each photo's projection and upload + date. KartaView's SINCE is the upload date (all its server can filter by); + a note under the slider says so while KartaView is lit (`sinceBasis: + 'upload'` in the provider contract). Mapillary's line coverage and sequence cones are now shared by both (`lineCoverage.js`, `sequenceCones.js`). diff --git a/README.md b/README.md index 9e914764b..f65d5e5d4 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** needs nothing: its public API is keyless. -**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary and KartaView; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. KartaView filters its lines on its own server, by upload date for **SINCE** (never before the capture date, so a line may hold only older captures: the click then says none of its photos pass), and a photo opened is held to its capture date; a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary and KartaView; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1` KartaView on, as the app writes it (`0.m.0_0.r.kartaview-1` works too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 89644fae5..cf29a4bc3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3225,13 +3225,15 @@ until the new ones are in), keeps the JSON segments, and caches masks per filter and tile. A dense filtered PNG can take KartaView over a minute the first time (it answers 504 at 60 s and keeps drawing), so a timeout or 5xx is asked again 4 s later, four tries in all; then the panel says it did not -filter the area in time. KartaView's `startDate` is the upload date, never -before the capture date, so the lines are a superset of what passes; the -nearest lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it -ignores `fieldOfView`) and upload date, then holds each photo to its capture -date (when photos are near but none passes, the panel says so), and the -cones pass the filter on each photo's projection and capture date, as -Mapillary's do. Tiles come uncompressed and +filter the area in time. KartaView's `startDate` is the upload date (its +server filters by nothing else), so SINCE goes by upload date throughout, +the cut-off taken as its UTC day (`passesKartaViewFilter`): the nearest +lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores +`fieldOfView`) and upload date and checks each answer the same way (when +photos are near but none passes, the panel says so), and the cones pass on +each photo's projection and upload date. The provider declares +`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by +upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and uncacheable, so the last 24 decoded tiles are kept for 10 minutes while KartaView is lit (`createTileCache`, dropped when it is switched off); one that does not answer in 20 s is asked once more, and one that is not a line diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index e5f76fe8c..c47a3f9f8 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -888,7 +888,7 @@ async function main() { await sleep(1000); } await step( - 'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters', + 'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters, SINCE noted as its upload date', async () => { await clickPanelControl(page, kvChip); assert.ok(await lit('kartaview'), 'KartaView alone is lit'); @@ -896,6 +896,14 @@ async function main() { const u = await ui(); assert.equal(u.keyRequired, false); assert.deepEqual(u.filterControls, { pano: true, since: true }); + // KartaView's SINCE is its upload date: the panel says so. + assert.equal(u.sinceNote, 'KartaView: by upload date'); + assert.equal( + await page.$eval('#sl-since-note', (node) => + node.hidden ? null : node.textContent, + ), + 'KartaView: by upload date', + ); assert.equal( await page.$eval(kvChip, (chip) => chip.style.getPropertyValue('--chip-color'), @@ -1613,9 +1621,10 @@ async function main() { ((i + 2) / 13) * (PHOTO_LINE.north - PHOTO_LINE.south), ]); // Ground primitives are built over a few frames: poll for a pick. + // A loaded machine renders a frame in seconds: 60 s, not 30. const handle = await page.waitForFunction( findPickPoint, - { timeout: 30_000, polling: 250 }, + { timeout: 60_000, polling: 250 }, { prefix: 'mly:seq:', id: target, @@ -1643,10 +1652,10 @@ async function main() { // The cones are on the globe: one of them picks as an image. Aim at // where each cone billboard is drawn (its ground position at // height 0 can sit a few pixels off, and every missed pick costs a - // render); the same 30 s as the line pick above. + // render); the same 60 s as the line pick above. const cone = await page.waitForFunction( findPickPoint, - { timeout: 30_000, polling: 250 }, + { timeout: 60_000, polling: 250 }, { prefix: 'mly:img:', id: null, diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 54076cc90..97b864d40 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -438,6 +438,7 @@ export function createStreetLevelLayer({ color: entry.def.colors.coverage, coverageLabel: entry.def.coverageLabel || null, highlightsSequence: entry.def.highlightsSequence !== false, + sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture', groundClick: entry.def.groundClick === true, filters: [...filtersOf(entry.def)], }; diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index cf932462b..ae05983a4 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -1,4 +1,3 @@ -import { passesImageryFilter } from '../../filter.js'; import { createLineCoverage } from '../../lineCoverage.js'; import { createSequenceCones, thinImages } from '../../sequenceCones.js'; import { metresBetween } from '../../view.js'; @@ -34,6 +33,7 @@ import { assignCourses, cameraBearing, normalizePhoto, + passesKartaViewFilter, tileFilterQuery, } from './policy.js'; @@ -133,6 +133,8 @@ export function createKartaViewProvider({ // 360° / flat and SINCE, as Mapillary: KartaView filters the lines on // its server (see tileFilterQuery), the photos by their own record. filters: Object.freeze(['pano', 'since']), + // SINCE is the upload date for KartaView: the panel notes it. + sinceBasis: 'upload', // Its lines are streets, not sequences: a selected drive shows as cones. highlightsSequence: false, coverageLabel: Object.freeze({ @@ -417,6 +419,7 @@ export function createKartaViewProvider({ lat: photo.lat, compassAngle: cameraBearing(photo) ?? 0, capturedAt: photo.capturedAt ?? 0, + uploadedAt: photo.uploadedAt, isPano: photo.isPano, })), ); @@ -425,7 +428,8 @@ export function createKartaViewProvider({ pickPrefix: PICK_PREFIX.image, spriteId: 'street-level:kartaview-cones', renderKey: 'kartaview-sequence', - // The imagery filter applies to cones as to Mapillary's (capture date). + // By projection and upload date, as the lines. + passes: passesKartaViewFilter, }); /** Sequence id → its photo count from KartaView (asked once). */ @@ -621,8 +625,7 @@ export function createKartaViewProvider({ if (!photo?.urls.view) continue; const metres = metresBetween({ lat, lon }, photo); if (metres > NEAREST_RADIUS_M) continue; - // KartaView filtered by upload date: hold it to the capture date. - if (!passesImageryFilter(photo, filter)) filteredOut++; + if (!passesKartaViewFilter(photo, filter)) filteredOut++; else near.push({ photo, metres }); } near.sort((a, b) => a.metres - b.metres); diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index d77353d6c..8d2c955ae 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -117,9 +117,9 @@ export const KARTAVIEW_CREDIT_HTML = * filter is asked for. So a filtered PNG tile is a mask, and a JSON line is * kept where it lies on the mask's drawn pixels. * - * KartaView's date filter is the upload date, never before the capture - * date: the lines are the streets with photos uploaded since the cut-off, - * and a photo opened from them is held to its capture date. + * KartaView's date filter is the upload date, and SINCE goes by it + * throughout (lines, the photo a click opens, the cones), as KartaView's own + * map does; the panel says so under the slider. */ export const MASK_TIMEOUT_MS = 20_000; /** @@ -139,6 +139,8 @@ export const MASK_SLOW_MESSAGE = 'KartaView did not filter this area in time — move the map to ask again'; export const FILTERED_OUT_MESSAGE = 'None of KartaView’s photos here pass the imagery filter'; +/** Under the SINCE slider while KartaView is lit. */ +export const SINCE_NOTE = 'KartaView: by upload date'; /** The UTC calendar day of `ms`, as KartaView's startDate reads it. */ function utcDay(ms) { @@ -148,6 +150,23 @@ function utcDay(ms) { const filtersSince = (filter) => Number.isFinite(filter?.sinceMs) && filter.sinceMs > 0; +const DAY_MS = 86_400_000; + +/** + * Whether a photo passes the resolved imagery filter by its projection and + * upload date, the cut-off taken as its UTC day, as KartaView's startDate. + * @param {{isPano: boolean, uploadedAt: number|null}} photo + * @param {{pano: string, sinceMs: number|null}|null} filter + */ +export function passesKartaViewFilter(photo, filter) { + if (!filter) return true; + if (filter.pano === 'pano' && !photo.isPano) return false; + if (filter.pano === 'flat' && photo.isPano) return false; + if (!filtersSince(filter)) return true; + const day = Math.floor(filter.sinceMs / DAY_MS) * DAY_MS; + return (photo.uploadedAt ?? 0) >= day; +} + /** * The query of a filtered PNG coverage tile, or '' when nothing is * filtered. The day, not the instant, so it is stable for a day (and keys @@ -164,7 +183,7 @@ export function tileFilterQuery(filter) { /** * The photo search's own filter: projection and upload date (its - * `fieldOfView` is ignored). The capture date is checked on the answers. + * `fieldOfView` is ignored). * @param {{pano: string, sinceMs: number|null}|null} filter resolved * @returns {Record} */ @@ -349,6 +368,8 @@ export function normalizePhoto(record) { isPano, capturedAt: kartaViewTimeMs(record.shotDate) ?? kartaViewTimeMs(record.dateAdded), + // KartaView's SINCE goes by this (its server filters by upload date). + uploadedAt: kartaViewTimeMs(record.dateAdded), urls: { // A 1280 px rendition; for a 360° photo it is the front view only. view: diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index fe23b8f48..82a68555e 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -7,6 +7,7 @@ import { kartaViewPhotoUrl, kartaViewTimeMs, normalizePhoto, + passesKartaViewFilter, photoFilterParams, resizedImageUrl, tileFilterQuery, @@ -307,3 +308,41 @@ test('the imagery filter becomes KartaView’s tile query and photo-search param projection: 'PLANE', }); }); + +test('KartaView’s SINCE is the upload date, from the start of the cut-off’s UTC day', () => { + const photo = normalizePhoto( + record({ + shotDate: '2016-08-23 20:17:47.000', + dateAdded: '2026-03-05 00:10:00', + }), + ); + assert.equal(photo.uploadedAt, Date.UTC(2026, 2, 5, 0, 10)); + const since = (iso) => ({ pano: 'all', sinceMs: Date.parse(iso) }); + // Taken in 2016, uploaded on the cut-off day: it passes, as KartaView's + // startDate=2026-03-05 keeps it. + assert.equal( + passesKartaViewFilter(photo, since('2026-03-05T23:00:00Z')), + true, + ); + assert.equal( + passesKartaViewFilter(photo, since('2026-03-06T00:00:00Z')), + false, + ); + assert.equal( + passesKartaViewFilter(photo, { pano: 'pano', sinceMs: null }), + false, + ); + assert.equal( + passesKartaViewFilter(photo, { pano: 'flat', sinceMs: null }), + true, + ); + assert.equal(passesKartaViewFilter(photo, null), true); + assert.equal( + passesKartaViewFilter( + { ...photo, uploadedAt: null }, + since('2020-01-01T00:00:00Z'), + ), + false, + 'no upload date passes no SINCE', + ); +}); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index 013a510f7..706988904 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -148,6 +148,11 @@ test('KartaView registers keyless, off by default, with Mapillary’s filters an assert.equal(definition.requiresKeyId, null); assert.equal(definition.defaultOn, false); assert.deepEqual(definition.filters, ['pano', 'since']); + assert.equal( + definition.sinceBasis, + 'upload', + 'SINCE is KartaView’s upload date', + ); assert.equal( definition.highlightsSequence, false, @@ -209,36 +214,44 @@ test('the nearest photo is the closest within 50 m, and opening it asks nothing ); }); -test('the nearest photo passes the imagery filter by its capture date; none passing says so', async () => { +test('the nearest photo passes the imagery filter by its upload date, as KartaView filters; none passing says so', async () => { const since = Date.now() - 86_400_000; const filter = { pano: 'pano', sinceMs: since }; - // KartaView filters the search by upload date: an old capture uploaded - // today still comes back, and is held to its capture date here. - const pano = (id, metres, shot) => ({ + const pano = (id, metres, uploaded) => ({ ...photoRecord(id, { metres }), projection: 'SPHERE', fieldOfView: '360', - shotDate: shot, + shotDate: '2016-08-23 20:17:47.000', + dateAdded: uploaded, }); + // Both taken in 2016; only the second was uploaded since yesterday. const { instance, source } = setup( { nearest: [ - pano('old', 5, '2016-08-23 20:17:47.000'), - pano('new', 20, shotAgo(3_600_000)), + pano('stale', 5, '2016-08-24 04:01:07'), + pano('fresh', 20, shotAgo(3_600_000).slice(0, 19)), ], }, { filter }, ); const at = { lat: 42.3314, lon: -83.0458 }; - assert.equal(await instance.nearestImage(at), 'new'); + assert.equal(await instance.nearestImage(at), 'fresh'); assert.deepEqual(source.calls.nearestFilters, [filter]); - const old = setup( - { nearest: [pano('old', 5, '2016-08-23 20:17:47.000')] }, + const stale = setup( + { nearest: [pano('stale', 5, '2016-08-24 04:01:07')] }, { filter }, ); - await assert.rejects(old.instance.nearestImage(at), { + await assert.rejects(stale.instance.nearestImage(at), { message: FILTERED_OUT_MESSAGE, }); + // Uploaded earlier on the cut-off's UTC day: KartaView's startDate keeps + // it, and so does the check. + const day = new Date(since).toISOString().slice(0, 10); + const sameDay = setup( + { nearest: [pano('early', 5, `${day} 00:00:01`)] }, + { filter }, + ); + assert.equal(await sameDay.instance.nearestImage(at), 'early'); }); test('a cone opens its photo; a line opens the photo nearest the click', () => { @@ -663,21 +676,22 @@ test('the cones re-centre when ‹ › walk off the page they were drawn from', ); }); -test('the imagery filter thins the cones by each photo’s capture date and projection', async (t) => { +test('the imagery filter thins the cones by each photo’s upload date and projection', async (t) => { const env = await mounted( { sequences: { s: [0, 1, 2, 3] } }, { filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 } }, ); - // 0 and 1: 360° photos taken an hour ago; 2 and 3: flat ones from 2016. + // 0 and 1: 360° photos uploaded an hour ago (taken in 2016, as all); + // 2 and 3: flat ones uploaded in 2016. const shaped = (record) => Number(record.sequenceIndex) < 2 ? { ...record, projection: 'SPHERE', fieldOfView: '360', - shotDate: shotAgo(3_600_000), + dateAdded: shotAgo(3_600_000).slice(0, 19), } - : record; + : { ...record, dateAdded: '2016-08-24 04:01:07' }; const getPhoto = env.source.getPhoto; env.source.getPhoto = async (id) => shaped(await getPhoto(id)); const getPage = env.source.getSequencePhotos; @@ -690,7 +704,7 @@ test('the imagery filter thins the cones by each photo’s capture date and proj await env.instance.selectSequence('s'); assert.ok(await until(() => !env.instance.sequenceStats().loading)); assert.equal(env.instance.sequenceStats().images, 4, 'the drive is whole'); - assert.equal(cones.count(), 2, 'the recent 360° photos'); + assert.equal(cones.count(), 2, 'the 360° photos uploaded since yesterday'); assert.deepEqual(cones(), [0, 1]); env.actions.filter = { pano: 'flat', sinceMs: null }; env.instance.setFilter(); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 68fff8444..3451447e4 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -24,6 +24,7 @@ export function filtersOf(provider) { * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). * @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one). + * @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit. * @property {(context: ProviderContext) => ProviderInstance} create * * @typedef {object} ProviderContext Handed to `create()` once by the core. @@ -160,6 +161,14 @@ export function validateProviders(providers) { throw new TypeError( `Street Level provider ${label}: filters must list ${IMAGERY_FILTERS.join(' or ')}`, ); + if ( + provider.sinceBasis !== undefined && + provider.sinceBasis !== 'capture' && + provider.sinceBasis !== 'upload' + ) + throw new TypeError( + `Street Level provider ${label}: sinceBasis must be 'capture' or 'upload'`, + ); ids.add(provider.id); prefixes.push(provider.pickPrefix); } diff --git a/src/layers/streetLevel/registry.test.mjs b/src/layers/streetLevel/registry.test.mjs index 7981d9acf..1783d7f42 100644 --- a/src/layers/streetLevel/registry.test.mjs +++ b/src/layers/streetLevel/registry.test.mjs @@ -117,4 +117,9 @@ test('a provider may narrow the filters it honours and pick a view-cone marker', () => validateProviders([def({ marker: { kind: 'pegman' } })]), /marker must be/, ); + validateProviders([def({ sinceBasis: 'upload' })]); + assert.throws( + () => validateProviders([def({ sinceBasis: 'shot' })]), + /sinceBasis must be 'capture' or 'upload'/, + ); }); diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index cab0d12b1..4c4590b3e 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -19,6 +19,7 @@ import { COLORS } from './policy.js'; * @property {Array<'pano'|'since'>} [filters] * @property {{noun: string, action: string}|null} [coverageLabel] * @property {boolean} [highlightsSequence] Its lines turn cyan when their sequence is selected. + * @property {'capture'|'upload'} [sinceBasis] What its SINCE compares. */ /** @@ -72,6 +73,18 @@ export function filterControls(providers) { return { pano: honoured('pano'), since: honoured('since') }; } +/** + * The note under SINCE when a lit provider dates its imagery by upload, + * e.g. "KartaView: by upload date", or null. + * @param {Array} providers + */ +export function sinceNote(providers) { + const names = providers + .filter((p) => p.on && p.sinceBasis === 'upload') + .map((p) => p.name); + return names.length ? `${names.join(', ')}: by upload date` : null; +} + /** * The panel snapshot from core state and provider snapshots. The legend has * one swatch per active source that draws coverage lines, then Selected. @@ -112,6 +125,7 @@ export function composeUIState({ keyRejected, filter: { ...filter }, filterControls: filterControls(providers), + sinceNote: sinceNote(providers), providers: providers.map((p) => ({ ...p })), coverage, legend, diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index 813213c4a..21b5eefe0 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -128,6 +128,18 @@ test('the filter controls show only for a lit provider that honours them', () => ); }); +test('a lit provider that dates by upload puts a note under SINCE', () => { + const kartaView = provider({ + id: 'kartaview', + name: 'KartaView', + sinceBasis: 'upload', + }); + const note = (providers) => composeUIState({ ...base, providers }).sinceNote; + assert.equal(note([provider()]), null, 'Mapillary: capture date'); + assert.equal(note([kartaView]), 'KartaView: by upload date'); + assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit'); +}); + test('hint and error come from the first active provider that has one', () => { const ui = composeUIState({ ...base, diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 7e6759199..db1a626f5 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -73,6 +73,7 @@ export class StreetLevelControls { sinceRow: byId('sl-since-row'), sinceRange: byId('sl-since'), sinceLabel: byId('sl-since-label'), + sinceNote: byId('sl-since-note'), legend: byId('sl-legend'), followBtn: byId('sl-follow-btn'), viewerWrap: byId('sl-viewer-wrap'), @@ -527,6 +528,9 @@ export class StreetLevelControls { const shows = view.filterControls || { pano: true, since: true }; setProp(el.panoRow, 'hidden', !shows.pano); setProp(el.sinceRow, 'hidden', !shows.since); + // A lit source that dates by upload says so under the slider. + setProp(el.sinceNote, 'textContent', view.sinceNote || ''); + setProp(el.sinceNote, 'hidden', !view.sinceNote); setProp(el.controls, 'hidden', !shows.pano && !shows.since); setProp(el.legend, 'hidden', view.legend.length === 0); this._renderRadios( diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index a27afd468..eb572781b 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; import test from 'node:test'; import { StreetLevelControls } from './streetLevelControls.js'; import { createStreetLevelLayer } from '../layers/streetLevel/index.js'; @@ -313,6 +314,7 @@ function panelDom() { const sinceRow = add(controls, 'div', { id: 'sl-since-row' }); add(sinceRow, 'input', { id: 'sl-since' }); add(sinceRow, 'output', { id: 'sl-since-label' }); + add(sinceRow, 'small', { id: 'sl-since-note' }); add(settings, 'ul', { id: 'sl-legend' }); const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] }); add(metaRow, 'div', { id: 'sl-coverage-meta' }); @@ -1224,6 +1226,26 @@ test('a swap names every registered provider, also those hidden on this map', () ]); })); +test('a note under SINCE says what date it compares, only while one is given', () => + withDom(async (dom) => { + const state = uiState(); + const { layer } = stubPanel(dom, state); + const note = () => dom.root.querySelector('#sl-since-note'); + assert.equal(note().hidden, true, 'none for Mapillary'); + layer.publish({ ...state, sinceNote: 'KartaView: by upload date' }); + assert.equal(note().hidden, false); + assert.equal(note().textContent, 'KartaView: by upload date'); + layer.publish(state); + assert.equal(note().hidden, true); + // The panel's own markup has it, read out with the slider. + const template = fs.readFileSync( + new URL('./templates/layer-panels.html', import.meta.url), + 'utf8', + ); + assert.match(template, /]* hidden>/); + assert.match(template, /aria-describedby="sl-since-label sl-since-note"/); + })); + test('Street View lit hides the imagery filters and the legend; Mapillary lit shows them', () => withDom(async (dom) => { const state = uiState(); diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 9ecfd077e..616829095 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -224,6 +224,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { since: state.filterControls?.since !== false, }, since: presentSince(filter.sinceDays, now), + sinceNote: state.sinceNote || null, legend: state.legend || [], viewer: presentViewer(state), meta: presentMeta(state), diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index 0e7258978..324f28bb6 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -299,6 +299,17 @@ button.sl-status:focus-visible { outline-offset: 3px; } +.sl-since-note { + grid-column: 2; + font-family: var(--font-mono); + font-size: var(--sl-text-small); + color: var(--text-secondary); +} + +.sl-since-note[hidden] { + display: none; +} + .sl-since-label { grid-column: 2; font-family: var(--font-mono); diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 4a44c03eb..d079ebd11 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -128,8 +128,9 @@
- + ANY DATE +