mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat(street-level): IMAGERY and SINCE filters for KartaView
KartaView filters coverage on its server only in its PNG tiles (its JSON tiles come back empty with a filter), so a filtered tile's PNG is a mask over the cached JSON segments: a segment stays when at least half the points along it lie on drawn pixels. A filter change reloads the lines (lineCoverage.reload keeps the drawn ones until the new ones are in); masks are cached per filter and tile, and a filtered tile KartaView is still drawing (504 or a timeout) is asked again, four tries in all. The nearest lookup asks the photo API for the projection and date, and the cones follow the filter like Mapillary's. The gate fixture draws filtered PNG tiles and adds an older flat-only street; a new gate step checks 360 and LAST YEAR thin KartaView's lines and ALL brings them back. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
88a3ee3d44
commit
b42ab9bb02
16 files changed
+961
-78
No files matched your search
@@ -798,6 +798,15 @@ export function createLineCoverage({
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load every tile again (a source that filters on its server had its
|
||||
* filter changed); the lines drawn now show until the new ones are in.
|
||||
*/
|
||||
function reload() {
|
||||
retire();
|
||||
refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraw tiles draped or cast for the new surface mode, then re-pick the
|
||||
* zoom, since the ground height under the camera may have changed.
|
||||
@@ -919,6 +928,7 @@ export function createLineCoverage({
|
||||
clear,
|
||||
resetErrors,
|
||||
rebuild,
|
||||
reload,
|
||||
setSurface,
|
||||
recolorSequence,
|
||||
sequenceCount,
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { MASK_KEEP_SHARE, MASK_SAMPLE_PX } from './policy.js';
|
||||
|
||||
/** More than any real tile holds (a dense z13 tile has ~4,700 segments). */
|
||||
export const MAX_TILE_SEGMENTS = 20_000;
|
||||
/** A chained line stops growing here and the next one starts. */
|
||||
export const MAX_LINE_POINTS = 5_000;
|
||||
|
||||
/**
|
||||
* Decode a KartaView coverage tile (`{lines: [{track: [{lat, lng}]}]}`) into
|
||||
* line records for the shared coverage. The tile's lines are OSM way
|
||||
* segments with photos; segments that meet end to start are chained, which
|
||||
* cuts the line count about fourfold. Lines carry no sequence, date or
|
||||
* projection, so nothing can filter them.
|
||||
* The way segments of a KartaView coverage tile
|
||||
* (`{lines: [{track: [{lat, lng}]}]}`), as [lon, lat] point lists. Lines
|
||||
* carry no sequence, date or projection: a filter thins them through a mask
|
||||
* (see `maskedSegments`).
|
||||
* @param {object} payload
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
|
||||
* @returns {Array<Array<[number, number]>>}
|
||||
*/
|
||||
export function decodeCoverageTile(payload, { x, y, z }) {
|
||||
export function tileSegments(payload) {
|
||||
// Not cached as empty: an unreadable tile is asked for again.
|
||||
if (!Array.isArray(payload?.lines))
|
||||
throw new Error('KartaView sent an unreadable coverage tile');
|
||||
@@ -31,6 +31,18 @@ export function decodeCoverageTile(payload, { x, y, z }) {
|
||||
}
|
||||
if (points.length >= 2) segments.push(points);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segments chained into line records for the shared coverage: segments
|
||||
* that meet end to start are joined, which cuts the line count about
|
||||
* fourfold.
|
||||
* @param {Array<Array<[number, number]>>} segments
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
|
||||
*/
|
||||
export function chainSegments(segments, { x, y, z }) {
|
||||
const key = ([lon, lat]) => `${lon},${lat}`;
|
||||
const startingAt = new Map();
|
||||
segments.forEach((segment, index) => {
|
||||
@@ -74,3 +86,91 @@ export function decodeCoverageTile(payload, { x, y, z }) {
|
||||
});
|
||||
return { sequences, overview: [] };
|
||||
}
|
||||
|
||||
/** Decode a KartaView coverage tile into the shared coverage's line records. */
|
||||
export function decodeCoverageTile(payload, address) {
|
||||
return chainSegments(tileSegments(payload), address);
|
||||
}
|
||||
|
||||
/**
|
||||
* The segments a filtered PNG tile of the same address draws over. The PNG
|
||||
* draws the same ways a few pixels wide (on a transparent ground): a
|
||||
* segment is kept when at least half the points sampled along it have a
|
||||
* drawn pixel within one pixel. Segments either lie on it or miss it
|
||||
* almost entirely (Jakarta, 360° only: 332 of 1,041 kept, 676 missed, 33
|
||||
* between). A segment with no point on the tile is not this tile's to keep.
|
||||
* @param {Array<Array<[number, number]>>} segments
|
||||
* @param {{width: number, height: number, alpha: ArrayLike<number>}} mask one alpha byte per pixel
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
*/
|
||||
export function maskedSegments(segments, mask, { x, y, z }) {
|
||||
const { width, height, alpha } = mask;
|
||||
const n = 2 ** z;
|
||||
const toPixel = ([lon, lat]) => {
|
||||
const sin = Math.sin((lat * Math.PI) / 180);
|
||||
const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
|
||||
return [(((lon + 180) / 360) * n - x) * width, (my * n - y) * height];
|
||||
};
|
||||
const drawnNear = (px, py) => {
|
||||
const cx = Math.floor(px);
|
||||
const cy = Math.floor(py);
|
||||
for (let dy = -1; dy <= 1; dy++)
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const X = cx + dx;
|
||||
const Y = cy + dy;
|
||||
if (X < 0 || Y < 0 || X >= width || Y >= height) continue;
|
||||
if (alpha[Y * width + X] > 0) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return segments.filter((segment) => {
|
||||
let on = 0;
|
||||
let tested = 0;
|
||||
let [ax, ay] = toPixel(segment[0]);
|
||||
for (let i = 1; i < segment.length; i++) {
|
||||
const [bx, by] = toPixel(segment[i]);
|
||||
const steps = Math.max(
|
||||
1,
|
||||
Math.ceil(Math.hypot(bx - ax, by - ay) / MASK_SAMPLE_PX),
|
||||
);
|
||||
// Each point once: a segment's next stretch starts where this one ends.
|
||||
for (let j = i === 1 ? 0 : 1; j <= steps; j++) {
|
||||
const px = ax + ((bx - ax) * j) / steps;
|
||||
const py = ay + ((by - ay) * j) / steps;
|
||||
if (px < 0 || py < 0 || px >= width || py >= height) continue;
|
||||
tested++;
|
||||
if (drawnNear(px, py)) on++;
|
||||
}
|
||||
ax = bx;
|
||||
ay = by;
|
||||
}
|
||||
return tested > 0 && on / tested >= MASK_KEEP_SHARE;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A PNG mask's alpha, one byte per pixel, decoded off the main thread
|
||||
* where the browser can.
|
||||
* @param {Uint8Array} bytes the PNG
|
||||
* @returns {Promise<{width: number, height: number, alpha: Uint8Array}>}
|
||||
*/
|
||||
export async function readMaskImage(bytes) {
|
||||
const bitmap = await createImageBitmap(
|
||||
new Blob([bytes], { type: 'image/png' }),
|
||||
);
|
||||
try {
|
||||
const { width, height } = bitmap;
|
||||
const canvas =
|
||||
typeof OffscreenCanvas === 'function'
|
||||
? new OffscreenCanvas(width, height)
|
||||
: Object.assign(document.createElement('canvas'), { width, height });
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const rgba = context.getImageData(0, 0, width, height).data;
|
||||
const alpha = new Uint8Array(width * height);
|
||||
for (let i = 0; i < alpha.length; i++) alpha[i] = rgba[i * 4 + 3];
|
||||
return { width, height, alpha };
|
||||
} finally {
|
||||
bitmap.close?.();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import test from 'node:test';
|
||||
import {
|
||||
MAX_LINE_POINTS,
|
||||
MAX_TILE_SEGMENTS,
|
||||
chainSegments,
|
||||
decodeCoverageTile,
|
||||
maskedSegments,
|
||||
tileSegments,
|
||||
} from './decode.js';
|
||||
|
||||
const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) });
|
||||
@@ -126,3 +129,59 @@ test('a tile is capped in segments and in points per line', () => {
|
||||
sequences.length >= Math.floor(MAX_TILE_SEGMENTS / MAX_LINE_POINTS),
|
||||
);
|
||||
});
|
||||
|
||||
/** [lon, lat] at pixel (px, py) of a 512 px mask of `tile` (Web Mercator). */
|
||||
function lonLatAt(px, py, { x, y, z } = TILE, size = 512) {
|
||||
const n = 2 ** z;
|
||||
const lon = ((x + px / size) / n) * 360 - 180;
|
||||
const my = Math.PI * (1 - (2 * (y + py / size)) / n);
|
||||
const lat = (Math.atan(Math.sinh(my)) * 180) / Math.PI;
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
/** A 512 px mask drawn on the given rows. */
|
||||
function rowsMask(rows) {
|
||||
const alpha = new Uint8Array(512 * 512);
|
||||
for (const [from, to] of rows) alpha.fill(255, from * 512, (to + 1) * 512);
|
||||
return { width: 512, height: 512, alpha };
|
||||
}
|
||||
|
||||
test('a mask keeps the segments it draws over, chained as before', () => {
|
||||
const along = (row, from = 40, to = 470) => [
|
||||
lonLatAt(from, row),
|
||||
lonLatAt(to, row),
|
||||
];
|
||||
const segments = [
|
||||
along(100), // drawn at row 100
|
||||
along(101.6), // a pixel off: still drawn within one pixel
|
||||
along(300), // not drawn
|
||||
// Two thirds along the drawn row, then off it: kept (at least half).
|
||||
[lonLatAt(40, 100), lonLatAt(400, 100), lonLatAt(400, 280)],
|
||||
// A third along it, then off: dropped.
|
||||
[lonLatAt(300, 100), lonLatAt(400, 100), lonLatAt(400, 300)],
|
||||
// Off the tile entirely: not this tile's to keep.
|
||||
[lonLatAt(-200, 100), lonLatAt(-20, 100)],
|
||||
];
|
||||
const kept = maskedSegments(segments, rowsMask([[99, 100]]), TILE);
|
||||
assert.deepEqual(kept, [segments[0], segments[1], segments[3]]);
|
||||
assert.deepEqual(maskedSegments(segments, rowsMask([]), TILE), []);
|
||||
// A full mask keeps every segment that is on the tile.
|
||||
assert.equal(maskedSegments(segments, rowsMask([[0, 511]]), TILE).length, 5);
|
||||
});
|
||||
|
||||
test('decoding is segments then chaining: the same lines either way', () => {
|
||||
const payload = {
|
||||
lines: [
|
||||
{ track: [at(1, 1), at(2, 1)] },
|
||||
{ track: [at(2, 1), at(3, 1)] },
|
||||
{ track: [at(5, 5), at(6, 6)] },
|
||||
],
|
||||
};
|
||||
const segments = tileSegments(payload);
|
||||
assert.equal(segments.length, 3);
|
||||
assert.deepEqual(
|
||||
chainSegments(segments, TILE),
|
||||
decodeCoverageTile(payload, TILE),
|
||||
);
|
||||
assert.throws(() => tileSegments({}), /unreadable coverage tile/);
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import { createLineCoverage } from '../../lineCoverage.js';
|
||||
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
import {
|
||||
chainSegments,
|
||||
maskedSegments,
|
||||
readMaskImage,
|
||||
tileSegments,
|
||||
} from './decode.js';
|
||||
import { createKartaViewSource } from './source.js';
|
||||
import { createKartaViewViewer } from './viewer.js';
|
||||
import {
|
||||
@@ -11,6 +17,7 @@ import {
|
||||
COVERAGE_NOUN,
|
||||
COVERAGE_STREET_ZOOM_BELOW_M,
|
||||
COVERAGE_TUNING,
|
||||
FILTERED_OUT_MESSAGE,
|
||||
KARTAVIEW_CREDIT_HTML,
|
||||
KARTAVIEW_LABEL,
|
||||
KARTAVIEW_NAME,
|
||||
@@ -27,13 +34,14 @@ import {
|
||||
assignCourses,
|
||||
cameraBearing,
|
||||
normalizePhoto,
|
||||
tileFilterQuery,
|
||||
} from './policy.js';
|
||||
|
||||
/** Photos kept in memory by id (sequence pages fill it). */
|
||||
const PHOTO_CACHE_SIZE = 4000;
|
||||
/** Sequence pages kept in memory. */
|
||||
const PAGE_CACHE_SIZE = 12;
|
||||
/** Decoded coverage tiles kept, and for how long. */
|
||||
/** Decoded coverage tiles kept, and for how long (masks too). */
|
||||
const TILE_CACHE_SIZE = 24;
|
||||
const TILE_CACHE_TTL_MS = 10 * 60_000;
|
||||
|
||||
@@ -77,11 +85,12 @@ export function createTileCache({
|
||||
size = TILE_CACHE_SIZE,
|
||||
ttlMs = TILE_CACHE_TTL_MS,
|
||||
now = Date.now,
|
||||
keyOf = (tile) => `${tile.z}/${tile.x}/${tile.y}`,
|
||||
}) {
|
||||
const kept = createLru(size);
|
||||
return {
|
||||
async get(tile, options) {
|
||||
const key = `${tile.z}/${tile.x}/${tile.y}`;
|
||||
const key = keyOf(tile);
|
||||
const entry = kept.get(key);
|
||||
if (entry && now() - entry.at < ttlMs) return entry.tile;
|
||||
const loaded = await load(tile, options);
|
||||
@@ -102,12 +111,13 @@ function pageOf(index) {
|
||||
* KartaView Street Level provider: keyless coverage lines from KartaView's
|
||||
* tile API, the photos nearest a clicked line, cones along the photo's
|
||||
* sequence, and a flat or 360° viewer with ‹ › along the sequence.
|
||||
* @param {{source?: ReturnType<typeof createKartaViewSource>, viewerOptions?: object}} [options]
|
||||
* @param {{source?: ReturnType<typeof createKartaViewSource>, viewerOptions?: object, readMask?: typeof readMaskImage}} [options]
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createKartaViewProvider({
|
||||
source = createKartaViewSource(),
|
||||
viewerOptions = {},
|
||||
readMask = readMaskImage,
|
||||
} = {}) {
|
||||
return Object.freeze({
|
||||
id: KARTAVIEW_PROVIDER_ID,
|
||||
@@ -120,8 +130,9 @@ export function createKartaViewProvider({
|
||||
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
|
||||
credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }),
|
||||
defaultOn: false,
|
||||
// Coverage lines carry no date or projection: no filter could thin them.
|
||||
filters: [],
|
||||
// 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']),
|
||||
// Its lines are streets, not sequences: a selected drive shows as cones.
|
||||
highlightsSequence: false,
|
||||
coverageLabel: Object.freeze({
|
||||
@@ -315,12 +326,30 @@ export function createKartaViewProvider({
|
||||
return probe;
|
||||
}
|
||||
|
||||
// Decoded coverage tiles, kept a while: panning back asks nothing.
|
||||
// A tile's way segments, kept a while: panning back, or a filter
|
||||
// change, asks KartaView nothing more for them.
|
||||
const tiles = createTileCache({
|
||||
load: async (tile, { signal }) =>
|
||||
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
|
||||
tileSegments(await source.getTile(tile, { signal })),
|
||||
});
|
||||
const fetchTile = (tile, options) => tiles.get(tile, options);
|
||||
// A filtered tile's mask, by filter and tile.
|
||||
const masks = createTileCache({
|
||||
load: async ({ query, ...tile }, { signal }) =>
|
||||
readMask(await source.getTileMask(tile, query, { signal })),
|
||||
keyOf: ({ query, z, x, y }) => `${query}|${z}/${x}/${y}`,
|
||||
});
|
||||
/** The tile's lines under the filter now: all, or the masked ones. */
|
||||
async function fetchTile(tile, options) {
|
||||
const query = tileFilterQuery(context.getFilter());
|
||||
if (!query) return chainSegments(await tiles.get(tile, options), tile);
|
||||
const [segments, mask] = await Promise.all([
|
||||
tiles.get(tile, options),
|
||||
masks.get({ ...tile, query }, options),
|
||||
]);
|
||||
return chainSegments(maskedSegments(segments, mask, tile), tile);
|
||||
}
|
||||
/** The query the drawn lines were loaded under. */
|
||||
let drawnQuery = tileFilterQuery(context.getFilter());
|
||||
|
||||
/**
|
||||
* The next showable photo `delta` along the sequence, or null at its
|
||||
@@ -361,6 +390,7 @@ export function createKartaViewProvider({
|
||||
renderKey: 'kartaview-coverage',
|
||||
tuning: COVERAGE_TUNING,
|
||||
messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: TOO_HIGH_HINT },
|
||||
// Filtered on the way in (fetchTile): a line has no date to test.
|
||||
passes: () => true,
|
||||
});
|
||||
|
||||
@@ -395,8 +425,7 @@ export function createKartaViewProvider({
|
||||
pickPrefix: PICK_PREFIX.image,
|
||||
spriteId: 'street-level:kartaview-cones',
|
||||
renderKey: 'kartaview-sequence',
|
||||
// The filters are hidden for KartaView: one left set must not hide cones.
|
||||
passes: () => true,
|
||||
// The imagery filter applies to cones as to Mapillary's (capture date).
|
||||
});
|
||||
|
||||
/** Sequence id → its photo count from KartaView (asked once). */
|
||||
@@ -491,6 +520,7 @@ export function createKartaViewProvider({
|
||||
drawnPage = null;
|
||||
// Tens of MB of decoded lines are not kept for a source left off.
|
||||
tiles.clear();
|
||||
masks.clear();
|
||||
},
|
||||
|
||||
destroy(cesiumViewer) {
|
||||
@@ -499,12 +529,23 @@ export function createKartaViewProvider({
|
||||
coverage.resetErrors();
|
||||
cones.destroy(cesiumViewer);
|
||||
tiles.clear();
|
||||
masks.clear();
|
||||
},
|
||||
|
||||
refreshCoverage: () => coverage.refresh(),
|
||||
|
||||
/** No filter applies (see `filters`). */
|
||||
setFilter() {},
|
||||
/**
|
||||
* The filter changed: the lines come from KartaView again under it
|
||||
* (the old ones show meanwhile), the cones are drawn again.
|
||||
*/
|
||||
setFilter() {
|
||||
const query = tileFilterQuery(context.getFilter());
|
||||
if (query !== drawnQuery) {
|
||||
drawnQuery = query;
|
||||
coverage.reload();
|
||||
}
|
||||
cones.rerender();
|
||||
},
|
||||
|
||||
setSurface() {
|
||||
coverage.setSurface();
|
||||
@@ -568,13 +609,21 @@ export function createKartaViewProvider({
|
||||
* file at all, so the nearest few are tried in turn.
|
||||
*/
|
||||
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||
const records = await source.nearestPhotos({ lat, lon }, { signal });
|
||||
const filter = context.getFilter();
|
||||
const records = await source.nearestPhotos(
|
||||
{ lat, lon, filter },
|
||||
{ signal },
|
||||
);
|
||||
const near = [];
|
||||
let filteredOut = 0;
|
||||
for (const record of records) {
|
||||
const photo = remember(record);
|
||||
if (!photo?.urls.view) continue;
|
||||
const metres = metresBetween({ lat, lon }, photo);
|
||||
if (metres <= NEAREST_RADIUS_M) near.push({ photo, metres });
|
||||
if (metres > NEAREST_RADIUS_M) continue;
|
||||
// KartaView filtered by upload date: hold it to the capture date.
|
||||
if (!passesImageryFilter(photo, filter)) filteredOut++;
|
||||
else near.push({ photo, metres });
|
||||
}
|
||||
near.sort((a, b) => a.metres - b.metres);
|
||||
// Probed side by side (photo storage is not the API host): the
|
||||
@@ -586,6 +635,7 @@ export function createKartaViewProvider({
|
||||
const hit = candidates.find((_, i) => answers[i]);
|
||||
if (hit) return hit.photo.id;
|
||||
if (near.length) throw new Error(ARCHIVED_MESSAGE);
|
||||
if (filteredOut) throw new Error(FILTERED_OUT_MESSAGE);
|
||||
return null;
|
||||
},
|
||||
|
||||
|
||||
@@ -111,6 +111,71 @@ export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]);
|
||||
export const KARTAVIEW_CREDIT_HTML =
|
||||
'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, CC BY-SA 4.0';
|
||||
|
||||
/**
|
||||
* The imagery filter (360° or flat, SINCE). KartaView filters coverage on
|
||||
* its server only in its PNG tiles: its JSON tiles come back empty once a
|
||||
* 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.
|
||||
*/
|
||||
export const MASK_TIMEOUT_MS = 20_000;
|
||||
/**
|
||||
* A filtered PNG tile is drawn on demand and a dense one can outlast
|
||||
* KartaView's own 60 s (504); it keeps drawing, and has the tile a few
|
||||
* seconds later. A timeout or a 5xx is asked again this many times in all.
|
||||
*/
|
||||
export const MASK_ATTEMPTS = 4;
|
||||
export const MASK_RETRY_WAIT_MS = 4_000;
|
||||
/** Real masks are tens of kilobytes. */
|
||||
export const MASK_MAX_BYTES = 2 * 1024 * 1024;
|
||||
/** A segment is kept when this share of the points along it lies on the mask. */
|
||||
export const MASK_KEEP_SHARE = 0.5;
|
||||
/** Points along a segment are tested this many mask pixels apart. */
|
||||
export const MASK_SAMPLE_PX = 2;
|
||||
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';
|
||||
|
||||
/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */
|
||||
function utcDay(ms) {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const filtersSince = (filter) =>
|
||||
Number.isFinite(filter?.sinceMs) && filter.sinceMs > 0;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* the mask cache).
|
||||
* @param {{pano: string, sinceMs: number|null}|null} filter resolved
|
||||
*/
|
||||
export function tileFilterQuery(filter) {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.pano === 'pano') params.set('fieldOfView', '360');
|
||||
else if (filter?.pano === 'flat') params.set('fieldOfView', 'plane');
|
||||
if (filtersSince(filter)) params.set('startDate', utcDay(filter.sinceMs));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The photo search's own filter: projection and upload date (its
|
||||
* `fieldOfView` is ignored). The capture date is checked on the answers.
|
||||
* @param {{pano: string, sinceMs: number|null}|null} filter resolved
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function photoFilterParams(filter) {
|
||||
const params = {};
|
||||
if (filter?.pano === 'pano') params.projection = 'SPHERE';
|
||||
else if (filter?.pano === 'flat') params.projection = 'PLANE';
|
||||
if (filtersSince(filter)) params.startDate = utcDay(filter.sinceMs);
|
||||
return params;
|
||||
}
|
||||
|
||||
/** The cone and coverage noun: a line is a street with photos, not a sequence. */
|
||||
export const COVERAGE_NOUN = 'streets';
|
||||
export const LINE_CLICK_HINT = 'click a line for its nearest photo';
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
kartaViewPhotoUrl,
|
||||
kartaViewTimeMs,
|
||||
normalizePhoto,
|
||||
photoFilterParams,
|
||||
resizedImageUrl,
|
||||
tileFilterQuery,
|
||||
} from './policy.js';
|
||||
|
||||
/** A photo record as /2.0/photo returns it (trimmed to what is read). */
|
||||
@@ -280,3 +282,28 @@ test('the camera looks along the drive: each photo’s course points to the next
|
||||
1e-9,
|
||||
);
|
||||
});
|
||||
|
||||
test('the imagery filter becomes KartaView’s tile query and photo-search params', () => {
|
||||
// 2026-03-05 23:30 UTC: the cut-off day is the UTC one.
|
||||
const sinceMs = Date.UTC(2026, 2, 5, 23, 30);
|
||||
assert.equal(tileFilterQuery(null), '');
|
||||
assert.equal(tileFilterQuery({ pano: 'all', sinceMs: null }), '');
|
||||
assert.equal(
|
||||
tileFilterQuery({ pano: 'pano', sinceMs: null }),
|
||||
'fieldOfView=360',
|
||||
);
|
||||
assert.equal(
|
||||
tileFilterQuery({ pano: 'flat', sinceMs }),
|
||||
'fieldOfView=plane&startDate=2026-03-05',
|
||||
);
|
||||
assert.equal(tileFilterQuery({ pano: 'all', sinceMs: 0 }), '');
|
||||
// The search ignores fieldOfView; its projection filters instead.
|
||||
assert.deepEqual(photoFilterParams({ pano: 'all', sinceMs: null }), {});
|
||||
assert.deepEqual(photoFilterParams({ pano: 'pano', sinceMs }), {
|
||||
projection: 'SPHERE',
|
||||
startDate: '2026-03-05',
|
||||
});
|
||||
assert.deepEqual(photoFilterParams({ pano: 'flat', sinceMs: null }), {
|
||||
projection: 'PLANE',
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
kartaViewZoomFor,
|
||||
} from './index.js';
|
||||
import {
|
||||
FILTERED_OUT_MESSAGE,
|
||||
NO_COVERAGE_HINT,
|
||||
SEQUENCE_PAGE_SIZE,
|
||||
TOO_HIGH_HINT,
|
||||
@@ -36,13 +37,23 @@ function photoRecord(id, { metres = 0, seq = '14010', index = 0 } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** KartaView's shotDate for `ms` ago. */
|
||||
const shotAgo = (ms) =>
|
||||
new Date(Date.now() - ms).toISOString().replace('T', ' ').slice(0, 23);
|
||||
|
||||
/**
|
||||
* A source over `sequences` (id → indexes present, in page order: KartaView
|
||||
* pages follow photo ids, which only roughly follow the index), recording
|
||||
* calls. Photos whose id is in `archived` answer 409.
|
||||
*/
|
||||
function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) {
|
||||
const calls = { nearest: 0, photo: [], pages: [], probes: [] };
|
||||
const calls = {
|
||||
nearest: 0,
|
||||
nearestFilters: [],
|
||||
photo: [],
|
||||
pages: [],
|
||||
probes: [],
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
probeImage: async (url) => {
|
||||
@@ -50,8 +61,9 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) {
|
||||
return !archived.some((id) => url.includes(`/${id}.jpg`));
|
||||
},
|
||||
getTile: async () => ({ lines: [] }),
|
||||
nearestPhotos: async () => {
|
||||
nearestPhotos: async ({ filter }) => {
|
||||
calls.nearest++;
|
||||
calls.nearestFilters.push(filter);
|
||||
return nearest;
|
||||
},
|
||||
getPhoto: async (id) => {
|
||||
@@ -80,12 +92,13 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) {
|
||||
}
|
||||
|
||||
function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) {
|
||||
const actions = { opened: [], nearest: [], instance: null };
|
||||
// `actions.filter` is the resolved filter now; a test may change it.
|
||||
const actions = { opened: [], nearest: [], instance: null, filter };
|
||||
return {
|
||||
actions,
|
||||
context: {
|
||||
services: {},
|
||||
getFilter: () => filter,
|
||||
getFilter: () => actions.filter,
|
||||
isActive: () => true,
|
||||
groundCaster: null,
|
||||
meshSampler: null,
|
||||
@@ -105,11 +118,12 @@ function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function setup(sourceOptions, contextOptions) {
|
||||
function setup(sourceOptions, contextOptions, { readMask } = {}) {
|
||||
const source = fakeSource(sourceOptions);
|
||||
const loads = [];
|
||||
const definition = createKartaViewProvider({
|
||||
source,
|
||||
readMask,
|
||||
viewerOptions: {
|
||||
load: async (url) => {
|
||||
loads.push(url);
|
||||
@@ -128,12 +142,12 @@ function setup(sourceOptions, contextOptions) {
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('KartaView registers keyless, off by default, with no filters and its own wording', () => {
|
||||
test('KartaView registers keyless, off by default, with Mapillary’s filters and its own wording', () => {
|
||||
const { definition } = setup();
|
||||
assert.equal(definition.id, 'kartaview');
|
||||
assert.equal(definition.requiresKeyId, null);
|
||||
assert.equal(definition.defaultOn, false);
|
||||
assert.deepEqual(definition.filters, []);
|
||||
assert.deepEqual(definition.filters, ['pano', 'since']);
|
||||
assert.equal(
|
||||
definition.highlightsSequence,
|
||||
false,
|
||||
@@ -195,6 +209,38 @@ 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 () => {
|
||||
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) => ({
|
||||
...photoRecord(id, { metres }),
|
||||
projection: 'SPHERE',
|
||||
fieldOfView: '360',
|
||||
shotDate: shot,
|
||||
});
|
||||
const { instance, source } = setup(
|
||||
{
|
||||
nearest: [
|
||||
pano('old', 5, '2016-08-23 20:17:47.000'),
|
||||
pano('new', 20, shotAgo(3_600_000)),
|
||||
],
|
||||
},
|
||||
{ filter },
|
||||
);
|
||||
const at = { lat: 42.3314, lon: -83.0458 };
|
||||
assert.equal(await instance.nearestImage(at), 'new');
|
||||
assert.deepEqual(source.calls.nearestFilters, [filter]);
|
||||
const old = setup(
|
||||
{ nearest: [pano('old', 5, '2016-08-23 20:17:47.000')] },
|
||||
{ filter },
|
||||
);
|
||||
await assert.rejects(old.instance.nearestImage(at), {
|
||||
message: FILTERED_OUT_MESSAGE,
|
||||
});
|
||||
});
|
||||
|
||||
test('a cone opens its photo; a line opens the photo nearest the click', () => {
|
||||
const { instance, actions } = setup();
|
||||
actions.instance = null;
|
||||
@@ -617,34 +663,72 @@ test('the cones re-centre when ‹ › walk off the page they were drawn from',
|
||||
);
|
||||
});
|
||||
|
||||
test('a leftover imagery filter hides no cones: flat, undated photos still draw', async (t) => {
|
||||
test('the imagery filter thins the cones by each photo’s capture date and projection', async (t) => {
|
||||
const env = await mounted(
|
||||
{ sequences: { s: [0, 1, 2, 3] } },
|
||||
{ filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 } },
|
||||
);
|
||||
// Undated records: no capture time to pass a "since" filter with.
|
||||
const undated = (record) => ({ ...record, shotDate: '', dateAdded: '' });
|
||||
// 0 and 1: 360° photos taken an hour ago; 2 and 3: flat ones from 2016.
|
||||
const shaped = (record) =>
|
||||
Number(record.sequenceIndex) < 2
|
||||
? {
|
||||
...record,
|
||||
projection: 'SPHERE',
|
||||
fieldOfView: '360',
|
||||
shotDate: shotAgo(3_600_000),
|
||||
}
|
||||
: record;
|
||||
const getPhoto = env.source.getPhoto;
|
||||
env.source.getPhoto = async (id) => undated(await getPhoto(id));
|
||||
env.source.getPhoto = async (id) => shaped(await getPhoto(id));
|
||||
const getPage = env.source.getSequencePhotos;
|
||||
env.source.getSequencePhotos = async (...args) => {
|
||||
const page = await getPage(...args);
|
||||
return { ...page, photos: page.photos.map(undated) };
|
||||
return { ...page, photos: page.photos.map(shaped) };
|
||||
};
|
||||
const cones = await withCones(t, env);
|
||||
await env.instance.viewer.open('s-1');
|
||||
await env.instance.selectSequence('s');
|
||||
assert.ok(await until(() => !env.instance.sequenceStats().loading));
|
||||
assert.equal(env.instance.sequenceStats().images, 4);
|
||||
assert.equal(cones.count(), 4, 'every cone drawn');
|
||||
assert.equal(env.instance.sequenceStats().images, 4, 'the drive is whole');
|
||||
assert.equal(cones.count(), 2, 'the recent 360° photos');
|
||||
assert.deepEqual(cones(), [0, 1]);
|
||||
env.actions.filter = { pano: 'flat', sinceMs: null };
|
||||
env.instance.setFilter();
|
||||
assert.deepEqual(cones(), [2, 3], 'the flat ones');
|
||||
env.actions.filter = { pano: 'all', sinceMs: null };
|
||||
env.instance.setFilter();
|
||||
assert.equal(cones.count(), 4, 'every cone, unfiltered');
|
||||
});
|
||||
|
||||
/**
|
||||
* The provider drawing coverage on a stand-in viewer above one z14 tile.
|
||||
* `lines` are the tile's tracks as [lon, lat] pairs; `getTile` may be held.
|
||||
*/
|
||||
function coverageSetup(t, { lines = [], filter, height = 900 } = {}) {
|
||||
const env = setup({}, { filter });
|
||||
function coverageSetup(
|
||||
t,
|
||||
{ lines = [], filter, height = 900, drawnRows = () => [] } = {},
|
||||
) {
|
||||
// A filtered tile's mask: 512 px square, drawn on the rows
|
||||
// `drawnRows(query)` lists as [from, to].
|
||||
const env = setup(
|
||||
{},
|
||||
{ filter },
|
||||
{
|
||||
readMask: async ({ query }) => {
|
||||
const alpha = new Uint8Array(512 * 512);
|
||||
for (const [from, to] of drawnRows(query))
|
||||
alpha.fill(255, from * 512, (to + 1) * 512);
|
||||
return { width: 512, height: 512, alpha };
|
||||
},
|
||||
},
|
||||
);
|
||||
const masks = [];
|
||||
let maskHold = null;
|
||||
env.source.getTileMask = async (tile, query) => {
|
||||
masks.push(query);
|
||||
await maskHold;
|
||||
return { query };
|
||||
};
|
||||
const centre = tileCentre(-83.0458, 42.3314);
|
||||
const viewer = coverageViewer(
|
||||
{ lon: centre.lon, lat: centre.lat, height },
|
||||
@@ -671,6 +755,15 @@ function coverageSetup(t, { lines = [], filter, height = 900 } = {}) {
|
||||
viewer,
|
||||
centre,
|
||||
tiles,
|
||||
masks,
|
||||
holdMasks() {
|
||||
let release;
|
||||
maskHold = new Promise((resolve) => (release = resolve));
|
||||
return () => {
|
||||
maskHold = null;
|
||||
release();
|
||||
};
|
||||
},
|
||||
holdTiles() {
|
||||
let release;
|
||||
hold = new Promise((resolve) => (release = resolve));
|
||||
@@ -682,20 +775,59 @@ function coverageSetup(t, { lines = [], filter, height = 900 } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test('a leftover imagery filter hides no KartaView line', async (t) => {
|
||||
const { instance, viewer, centre } = coverageSetup(t, {
|
||||
filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 },
|
||||
lines: [
|
||||
[
|
||||
[-83.0458 - 0.001, 42.3314],
|
||||
[-83.0458 + 0.001, 42.3314],
|
||||
],
|
||||
],
|
||||
test('a filter thins the lines to those its PNG mask draws; without one they all show', async (t) => {
|
||||
const { lon, lat } = tileCentre(-83.0458, 42.3314);
|
||||
// Across the tile's middle row (mask row ~256), and a quarter up (~130).
|
||||
const across = (at) => [
|
||||
[lon - 0.001, at],
|
||||
[lon + 0.001, at],
|
||||
];
|
||||
const env = coverageSetup(t, {
|
||||
filter: { pano: 'pano', sinceMs: null },
|
||||
lines: [across(lat), across(lat + 0.004)],
|
||||
// 360° photos lie on the middle line only.
|
||||
drawnRows: (query) => (query === 'fieldOfView=360' ? [[254, 258]] : []),
|
||||
});
|
||||
assert.ok(centre);
|
||||
instance.activate(viewer);
|
||||
assert.ok(await until(() => instance.coverageStats().count === 1));
|
||||
assert.equal(instance.coverageStats().hint, '');
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 1));
|
||||
assert.deepEqual(env.masks, ['fieldOfView=360']);
|
||||
// Unfiltered: every line, and the tile's lines are not asked for again.
|
||||
env.actions.filter = { pano: 'all', sinceMs: null };
|
||||
env.instance.setFilter();
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 2));
|
||||
assert.equal(env.tiles.length, 1, 'the segments are kept');
|
||||
// SINCE a day: uploaded since yesterday (UTC) — none here. The lines
|
||||
// drawn now stay on the globe while KartaView draws the filtered tile.
|
||||
const since = Date.now() - 86_400_000;
|
||||
const release = env.holdMasks();
|
||||
env.actions.filter = { pano: 'all', sinceMs: since };
|
||||
env.instance.setFilter();
|
||||
assert.ok(await until(() => env.instance.coverageStats().loading));
|
||||
assert.ok(
|
||||
env.viewer.scene.groundPrimitives.items.size > 0,
|
||||
'the unfiltered lines show meanwhile',
|
||||
);
|
||||
release();
|
||||
assert.ok(
|
||||
await until(
|
||||
() =>
|
||||
!env.instance.coverageStats().loading &&
|
||||
env.instance.coverageStats().count === 0,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
env.masks.at(-1),
|
||||
`startDate=${new Date(since).toISOString().slice(0, 10)}`,
|
||||
);
|
||||
assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT);
|
||||
assert.ok(
|
||||
await until(() => env.viewer.scene.groundPrimitives.items.size === 0),
|
||||
'and go once it is in',
|
||||
);
|
||||
// The same filter again asks KartaView for nothing.
|
||||
env.instance.setFilter();
|
||||
for (let i = 0; i < 5; i++) await settle();
|
||||
assert.equal(env.masks.length, 2);
|
||||
});
|
||||
|
||||
test('coverage hints: zoom in from too high, “no photos” only once an empty view has loaded', async (t) => {
|
||||
|
||||
@@ -2,6 +2,11 @@ import {
|
||||
IMAGE_PROBE_TIMEOUT_MS,
|
||||
MAX_RESPONSE_BYTES,
|
||||
KARTAVIEW_API_HOST,
|
||||
MASK_ATTEMPTS,
|
||||
MASK_MAX_BYTES,
|
||||
MASK_RETRY_WAIT_MS,
|
||||
MASK_SLOW_MESSAGE,
|
||||
MASK_TIMEOUT_MS,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_SEARCH_ZOOM,
|
||||
PAGE_CONCURRENCY,
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
PHOTO_API_TIMEOUT_MS,
|
||||
TILE_CONCURRENCY,
|
||||
TILE_TIMEOUT_MS,
|
||||
photoFilterParams,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
@@ -121,19 +127,32 @@ export function createLimiter(limit, { lifo = false } = {}) {
|
||||
* waits behind slow tiles or pages. The photo API answers 429 to more than
|
||||
* about three requests at once; a 429 (and a lookup that timed out) is
|
||||
* retried.
|
||||
* @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number}} [options]
|
||||
* @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number, maskRetryMs?: number}} [options]
|
||||
*/
|
||||
export function createKartaViewSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
apiHost = KARTAVIEW_API_HOST,
|
||||
retryBaseMs = PHOTO_API_RETRY_BASE_MS,
|
||||
maskRetryMs = MASK_RETRY_WAIT_MS,
|
||||
} = {}) {
|
||||
const limiter = createLimiter(PHOTO_API_CONCURRENCY);
|
||||
const tileLimiter = createLimiter(TILE_CONCURRENCY);
|
||||
const pageLimiter = createLimiter(PAGE_CONCURRENCY, { lifo: true });
|
||||
|
||||
/** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */
|
||||
async function getJson(url, { signal, timeoutMs }) {
|
||||
function getJson(url, { signal, timeoutMs }) {
|
||||
return fetchBody(url, { signal, timeoutMs, as: 'json' });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a body within `timeoutMs`, at most `maxBytes` of it, as parsed JSON
|
||||
* or (`as: 'bytes'`) a Uint8Array; the caller's abort wins over the
|
||||
* timeout.
|
||||
*/
|
||||
async function fetchBody(
|
||||
url,
|
||||
{ signal, timeoutMs, as = 'json', maxBytes = MAX_RESPONSE_BYTES },
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort(signal.reason);
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
@@ -164,12 +183,14 @@ export function createKartaViewSource({
|
||||
new KartaViewSourceError('KartaView sent too much data', {
|
||||
status: response.status,
|
||||
});
|
||||
if (length > MAX_RESPONSE_BYTES) throw tooMuch();
|
||||
if (length > maxBytes) throw tooMuch();
|
||||
// KartaView answers chunked, without a length: count while reading.
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) {
|
||||
read = true;
|
||||
try {
|
||||
if (as === 'bytes')
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw error;
|
||||
@@ -185,7 +206,7 @@ export function createKartaViewSource({
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > MAX_RESPONSE_BYTES) {
|
||||
if (bytes > maxBytes) {
|
||||
reader.cancel().catch(() => {});
|
||||
throw tooMuch();
|
||||
}
|
||||
@@ -197,6 +218,7 @@ export function createKartaViewSource({
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
if (as === 'bytes') return body;
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(body));
|
||||
} catch {
|
||||
@@ -304,6 +326,41 @@ export function createKartaViewSource({
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* A filtered coverage tile as PNG bytes, `query` from tileFilterQuery
|
||||
* (KartaView filters only its PNG tiles). It draws a filtered tile on
|
||||
* demand; a dense one can outlast its own 60 s (504) and still be drawn,
|
||||
* so a timeout or a 5xx is asked again a few seconds later.
|
||||
*/
|
||||
async getTileMask({ x, y, z }, query, { signal } = {}) {
|
||||
const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.png?${query}`;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
// Outside the gate between tries: a wait holds no connection.
|
||||
return await tileLimiter.run(
|
||||
() =>
|
||||
fetchBody(url, {
|
||||
signal,
|
||||
timeoutMs: MASK_TIMEOUT_MS,
|
||||
as: 'bytes',
|
||||
maxBytes: MASK_MAX_BYTES,
|
||||
}),
|
||||
{ signal },
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
const drawing =
|
||||
error?.timedOut === true || [502, 503, 504].includes(error?.status);
|
||||
if (!drawing) throw error;
|
||||
if (attempt >= MASK_ATTEMPTS)
|
||||
throw new KartaViewSourceError(MASK_SLOW_MESSAGE, {
|
||||
timedOut: true,
|
||||
});
|
||||
await wait(maskRetryMs, signal);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a photo file is there: true, false when it is gone (archived
|
||||
* photos answer 409, and a HEAD says 200 regardless), or null when the
|
||||
@@ -335,8 +392,11 @@ export function createKartaViewSource({
|
||||
}
|
||||
},
|
||||
|
||||
/** Photo records around a point, nearest first. */
|
||||
async nearestPhotos({ lat, lon }, { signal } = {}) {
|
||||
/**
|
||||
* Photo records around a point, nearest first; with a resolved imagery
|
||||
* `filter`, only those of its projection uploaded since its cut-off.
|
||||
*/
|
||||
async nearestPhotos({ lat, lon, filter = null }, { signal } = {}) {
|
||||
const result = await photoApi(
|
||||
'/2.0/photo/',
|
||||
{
|
||||
@@ -344,6 +404,7 @@ export function createKartaViewSource({
|
||||
lng: lon.toFixed(7),
|
||||
zoomLevel: String(NEAREST_SEARCH_ZOOM),
|
||||
itemsPerPage: String(NEAREST_LIMIT),
|
||||
...photoFilterParams(filter),
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
createLimiter,
|
||||
} from './source.js';
|
||||
import {
|
||||
MASK_ATTEMPTS,
|
||||
MASK_SLOW_MESSAGE,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_SEARCH_ZOOM,
|
||||
PHOTO_API_CONCURRENCY,
|
||||
@@ -654,3 +656,73 @@ test('an answer turned away unread (an error, a 429, a length past the cap) lets
|
||||
assert.equal(seen.cancelled, true);
|
||||
assert.equal(signals[1].aborted, false, 'a read answer is left alone');
|
||||
});
|
||||
|
||||
test('a filtered tile comes as PNG bytes; KartaView still drawing it (504) is asked again, then given up', async () => {
|
||||
const png = new Uint8Array([137, 80, 78, 71, 1, 2, 3]);
|
||||
const bytesOf = () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map(),
|
||||
body: {
|
||||
getReader: () => {
|
||||
let given = false;
|
||||
return {
|
||||
read: async () =>
|
||||
given
|
||||
? { done: true }
|
||||
: ((given = true), { done: false, value: png }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const urls = [];
|
||||
let answers = [status(504), status(503), bytesOf()];
|
||||
const source = createKartaViewSource({
|
||||
maskRetryMs: 0,
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return answers.shift();
|
||||
},
|
||||
});
|
||||
const tile = { x: 13054, y: 8474, z: 14 };
|
||||
assert.deepEqual(await source.getTileMask(tile, 'fieldOfView=360'), png);
|
||||
assert.deepEqual(urls, [
|
||||
'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360',
|
||||
'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360',
|
||||
'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360',
|
||||
]);
|
||||
// Still drawing after every try: given up, saying so.
|
||||
urls.length = 0;
|
||||
answers = Array.from({ length: 10 }, () => status(504));
|
||||
await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), {
|
||||
message: MASK_SLOW_MESSAGE,
|
||||
timedOut: true,
|
||||
});
|
||||
assert.equal(urls.length, MASK_ATTEMPTS);
|
||||
// A refusal is not asked again.
|
||||
urls.length = 0;
|
||||
answers = [status(400)];
|
||||
await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), /HTTP 400/);
|
||||
assert.equal(urls.length, 1);
|
||||
});
|
||||
|
||||
test('a filtered nearest search asks KartaView for the projection and upload date', async () => {
|
||||
const urls = [];
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(new URL(url));
|
||||
return ok(photoPayload([]));
|
||||
},
|
||||
});
|
||||
await source.nearestPhotos({
|
||||
lat: 42.3314,
|
||||
lon: -83.0458,
|
||||
filter: { pano: 'pano', sinceMs: Date.UTC(2026, 0, 2, 12) },
|
||||
});
|
||||
await source.nearestPhotos({ lat: 42.3314, lon: -83.0458 });
|
||||
assert.equal(urls[0].searchParams.get('projection'), 'SPHERE');
|
||||
assert.equal(urls[0].searchParams.get('startDate'), '2026-01-02');
|
||||
assert.equal(urls[1].searchParams.has('projection'), false);
|
||||
assert.equal(urls[1].searchParams.has('startDate'), false);
|
||||
});
|
||||
Reference in new issue
Block a user