mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
b42ab9bb02
commit
c4ecb11f75
18 files changed
+210
-40
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'/,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user