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

+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>