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 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-14 15:02:14 +01:00
1 parent b42ab9bb02
commit c4ecb11f75
18 files changed
+210 -40

No files matched your search

+5 -3
View File
@@ -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`).
+1 -1
View File
@@ -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`.
+9 -7
View File
@@ -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
+13 -4
View File
@@ -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,
+1
View File
@@ -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)],
};
@@ -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);
@@ -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<string, string>}
*/
@@ -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:
@@ -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',
);
});
@@ -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();
+9
View File
@@ -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);
}
+5
View File
@@ -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'/,
);
});
+14
View File
@@ -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<ProviderSnapshot>} 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,
+12
View File
@@ -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,
+4
View File
@@ -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(
+22
View File
@@ -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, /<small id="sl-since-note"[^>]* 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();
+1
View File
@@ -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),
+11
View File
@@ -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);
+2 -1
View File
@@ -128,8 +128,9 @@
</div>
<div id="sl-since-row" class="sl-since">
<label class="cctv-summary-label" for="sl-since">SINCE</label>
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label" />
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label sl-since-note" />
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
<small id="sl-since-note" class="sl-since-note" hidden></small>
</div>
</fieldset>
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours"></ul>