From b42ab9bb02b7f7f1adf1e517e1caecfda10f0e31 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 14:54:12 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 6 +- DATA_SOURCES.md | 2 +- README.md | 2 +- docs/CURRENT-STATE.md | 22 +- scripts/fixtures/street-level/kartaView.mjs | 163 +++++++++++++-- scripts/qa-street-level.mjs | 70 ++++++- src/layers/streetLevel/lineCoverage.js | 10 + .../streetLevel/providers/kartaview/decode.js | 116 ++++++++++- .../providers/kartaview/decode.test.mjs | 59 ++++++ .../streetLevel/providers/kartaview/index.js | 80 ++++++-- .../streetLevel/providers/kartaview/policy.js | 65 ++++++ .../providers/kartaview/policy.test.mjs | 27 +++ .../providers/kartaview/provider.test.mjs | 190 +++++++++++++++--- .../streetLevel/providers/kartaview/source.js | 73 ++++++- .../providers/kartaview/source.test.mjs | 72 +++++++ src/tooling/streetLevelQa.test.mjs | 82 ++++++++ 16 files changed, 961 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abe3f8201..cde9b6883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,7 +114,11 @@ answers 409) are skipped by the click and by ‹ ›. Shift+← →, ↑ ↓, + − and pinch look around a 360° photo. The open photo's marker is a purple view cone pointing along the drive (KartaView's own compass headings are often - 0 or far off). + 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. Mapillary's line coverage and sequence cones are now shared by both (`lineCoverage.js`, `sequenceCones.js`). diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 23d9cfc82..1319c1541 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -22,7 +22,7 @@ How to read this: | **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" | | **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on | | **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer | -| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on | +| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on | | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | diff --git a/README.md b/README.md index e55c94a77..9e914764b 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** needs nothing: its public API is keyless. -**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; 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 photos are undated on its map, so the filters do not apply to it. 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. 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. Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1` KartaView on, as the app writes it (`0.m.0_0.r.kartaview-1` works too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7c98f62fb..89644fae5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3212,8 +3212,26 @@ segments that meet end to start (about four times fewer lines; linear time, at most 20,000 segments a tile and 5,000 points a line), they are drawn as `kv:line:`, up to 1,600 per tile, and never highlighted (`highlightsSequence: false`, so the legend has no "Selected"), and the -provider declares `filters: []` and `coverageLabel` ("N streets in view · -click a line for its nearest photo"). Tiles come uncompressed and +provider declares `filters: ['pano', 'since']` and `coverageLabel` ("N +streets in view · click a line for its nearest photo"). KartaView filters +coverage only in its PNG tiles (`/sequence/tiles/{x}/{y}/{z}.png?fieldOfView=360|plane&startDate=YYYY-MM-DD`; +its JSON, GeoJSON and jsoncanvas tiles come back empty once a filter is +asked for), so a filtered tile is drawn from the cached JSON segments that +lie on the filtered PNG's drawn pixels (`maskedSegments`: at least half the +points sampled every 2 px have a drawn pixel within one; on Jakarta's 360° +filter, 332 of 1,041 segments lie on it, 676 miss it, 33 fall between). +A filter change reloads the lines (`lineCoverage.reload`: the drawn ones show +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 uncacheable, so the last 24 decoded tiles are kept for 10 minutes while KartaView is lit (`createTileCache`, dropped when it is switched off); one that does not answer in 20 s is asked once more, and one that is not a line diff --git a/scripts/fixtures/street-level/kartaView.mjs b/scripts/fixtures/street-level/kartaView.mjs index 1c85b0ec0..91caa8004 100644 --- a/scripts/fixtures/street-level/kartaView.mjs +++ b/scripts/fixtures/street-level/kartaView.mjs @@ -1,3 +1,4 @@ +import { deflateSync } from 'node:zlib'; import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js'; import { metresApart, photoJpeg } from './mapillaryGraph.mjs'; @@ -11,6 +12,10 @@ import { metresApart, photoJpeg } from './mapillaryGraph.mjs'; * so the nearest photo opened is even and › must skip the one after it. The * first nearest-photo lookup is rate-limited (429, Retry-After: 0), as * KartaView does under load. + * + * Beside the drive runs KV_OLD_STREET, photographed flat in 2019 only: the + * imagery filter drops it. KartaView filters coverage only in its PNG + * tiles, so filtered PNG tiles are drawn here for the streets that pass. */ export const KV_SEQUENCE_ID = '900001'; @@ -19,9 +24,20 @@ export const KV_LINE = Object.freeze({ south: 38.5795, north: 38.5875, }); +/** A street ~130 m east of the drive with only flat 2019 photos. */ +export const KV_OLD_STREET = Object.freeze({ + lon: -121.4916, + south: 38.58, + north: 38.587, +}); +const OLD_STREET_UPLOADED = '2019-06-01'; const KV_SPACING_M = 25; const METRES_PER_DEG_LAT = 110_540; -const SHOT = '2024-05-02 16:20:00.000'; +/** KartaView's time format for `ms`. */ +const kartaViewTime = (ms) => + new Date(ms).toISOString().replace('T', ' ').slice(0, 23); +/** The drive is a month old, whenever the gate runs: LAST YEAR keeps it. */ +const SHOT = kartaViewTime(Date.now() - 30 * 86_400_000); const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' }); @@ -95,27 +111,123 @@ const raw = (status, body) => ({ body: JSON.stringify(body), }); -/** The drive's track inside tile `z/x/y` (with a small margin), as KartaView's JSON tile. */ -function tileLines(z, x, y) { - const { west, east, south, north } = tileBounds(x, y, z); - if (KV_LINE.lon < west || KV_LINE.lon > east) return []; - const from = Math.max(KV_LINE.south, south); - const to = Math.min(KV_LINE.north, north); - if (from >= to) return []; - // Two segments meeting end to start, as KartaView splits ways. - const mid = (from + to) / 2; - const at = (lat) => ({ lat: String(lat), lng: String(KV_LINE.lon) }); +/** The streets with photos: the drive, and the old street beside it. */ +function streets() { return [ - { track: [at(from), at(mid)], matched: '1' }, - { track: [at(mid), at(to)], matched: '1' }, + { line: KV_LINE, uploaded: SHOT.slice(0, 10), views: ['360', 'plane'] }, + { line: KV_OLD_STREET, uploaded: OLD_STREET_UPLOADED, views: ['plane'] }, ]; } +/** A street's stretch inside tile `z/x/y`, as [from, to] latitudes, or null. */ +function stretch(line, z, x, y) { + const { west, east, south, north } = tileBounds(x, y, z); + if (line.lon < west || line.lon > east) return null; + const from = Math.max(line.south, south); + const to = Math.min(line.north, north); + return from < to ? [from, to] : null; +} + +/** The streets' tracks inside tile `z/x/y`, as KartaView's JSON tile. */ +function tileLines(z, x, y) { + const lines = []; + for (const { line } of streets()) { + const inside = stretch(line, z, x, y); + if (!inside) continue; + const [from, to] = inside; + // Two segments meeting end to start, as KartaView splits ways. + const mid = (from + to) / 2; + const at = (lat) => ({ lat: String(lat), lng: String(line.lon) }); + lines.push( + { track: [at(from), at(mid)], matched: '1' }, + { track: [at(mid), at(to)], matched: '1' }, + ); + } + return lines; +} + +/** Whether a street has photos passing a filtered tile's query. */ +function passesTileQuery(street, query) { + const view = query.get('fieldOfView'); + if (view && !street.views.includes(view)) return false; + const since = query.get('startDate'); + return !since || street.uploaded >= since; +} + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +function crc32(bytes) { + let c = 0xffffffff; + for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function pngChunk(type, data) { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, 'ascii'); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); + return Buffer.concat([head, data, crc]); +} + +/** + * A filtered coverage tile as KartaView draws it: 512 px, transparent, the + * passing streets three pixels wide. + */ +export function maskTilePng(z, x, y, query) { + const size = 512; + const rgba = Buffer.alloc(size * size * 4); + const { west, east } = tileBounds(x, y, z); + const n = 2 ** z; + const row = (lat) => { + const sin = Math.sin((lat * Math.PI) / 180); + const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI); + return Math.round((my * n - y) * size); + }; + for (const street of streets()) { + if (!passesTileQuery(street, query)) continue; + const inside = stretch(street.line, z, x, y); + if (!inside) continue; + const column = Math.round( + ((street.line.lon - west) / (east - west)) * size, + ); + const top = Math.max(0, row(inside[1])); + const bottom = Math.min(size - 1, row(inside[0])); + for (let py = top; py <= bottom; py++) + for (let px = column - 1; px <= column + 1; px++) { + if (px < 0 || px >= size) continue; + rgba.set([99, 91, 255, 255], (py * size + px) * 4); + } + } + const rows = Buffer.alloc(size * (size * 4 + 1)); + for (let py = 0; py < size; py++) + rgba.copy( + rows, + py * (size * 4 + 1) + 1, + py * size * 4, + (py + 1) * size * 4, + ); + const header = Buffer.alloc(13); + header.writeUInt32BE(size, 0); + header.writeUInt32BE(size, 4); + header.set([8, 6, 0, 0, 0], 8); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk('IHDR', header), + pngChunk('IDAT', deflateSync(rows)), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + /** A call named by host, path shape and parameter names. */ export function describeKartaViewCall(method, url) { const keys = [...url.searchParams.keys()].sort(); const path = url.pathname .replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json') + .replace(/\/tiles\/\d+\/\d+\/\d+\.png$/, '/tiles/{x}/{y}/{z}.png') .replace(/\/photo\/\d+$/, '/photo/{id}') .replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos') .replace(/\/sequence\/\d+$/, '/sequence/{id}') @@ -173,6 +285,23 @@ export function answerKartaViewRequest({ method, url }) { }), known: true, }; + // Filtered coverage: KartaView filters only its PNG tiles. + const mask = path.match( + /^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.png$/, + ); + if (mask) + return { + status: 200, + contentType: 'image/png', + headers: CORS, + body: maskTilePng( + Number(mask[3]), + Number(mask[1]), + Number(mask[2]), + address.searchParams, + ), + known: true, + }; if (path === '/2.0/photo/') { if (nearestLookups++ === 0) return { @@ -186,7 +315,15 @@ export function answerKartaViewRequest({ method, url }) { }; const q = address.searchParams; const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) }; + // Its own filter, as KartaView's: projection, and upload date. + const projection = q.get('projection'); + const since = q.get('startDate'); const near = photos + .filter( + (photo) => + (!projection || projection === (photo.isPano ? 'SPHERE' : 'PLANE')) && + (!since || SHOT.slice(0, 10) >= since), + ) .map((photo) => ({ photo, metres: metresApart(at, photo) })) .filter(({ metres }) => metres < 200) .sort((a, b) => a.metres - b.metres) diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 4b60a0695..e5f76fe8c 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -888,14 +888,14 @@ async function main() { await sleep(1000); } await step( - 'KartaView: its chip draws keyless coverage, credits KartaView and hides the filters', + 'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters', async () => { await clickPanelControl(page, kvChip); assert.ok(await lit('kartaview'), 'KartaView alone is lit'); await waitForCoverage(page); const u = await ui(); assert.equal(u.keyRequired, false); - assert.deepEqual(u.filterControls, { pano: false, since: false }); + assert.deepEqual(u.filterControls, { pano: true, since: true }); assert.equal( await page.$eval(kvChip, (chip) => chip.style.getPropertyValue('--chip-color'), @@ -924,6 +924,72 @@ async function main() { ); }, ); + await step( + 'KartaView: 360° and SINCE thin its lines through KartaView’s filtered tiles, and ALL / ANY DATE bring them back', + async () => { + const before = (await ui()).coverage.count; + const setStop = (index) => + page.$eval( + '#sl-since', + (input, value) => { + input.value = String(value); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }, + index, + ); + // Filtered tiles are drawn by KartaView on demand: give them time. + const settled = async (test, arg, what) => + assert.ok( + await uiUntil(test, arg, { timeout: 60_000 }), + `${what}: ${JSON.stringify((await ui()).coverage)}`, + ); + await clickPanelControl(page, '[data-sl-pano="pano"]'); + await settled( + (u) => u.filter.pano === 'pano' && !u.coverage.loading, + null, + '360° drawn', + ); + const panos = (await ui()).coverage.count; + assert.ok(panos <= before, `${panos} ≤ ${before}`); + // Fixtures: the old street has only flat photos, the drive 360° too. + if (fixtures) + assert.ok(panos > 0 && panos < before, `0 < ${panos} < ${before}`); + await clickPanelControl(page, '[data-sl-pano="all"]'); + await settled( + (u, n) => + u.filter.pano === 'all' && + !u.coverage.loading && + u.coverage.count === n, + before, + 'every line again', + ); + await setStop(5); + await settled( + (u) => u.filter.sinceDays === 365 && !u.coverage.loading, + null, + 'LAST YEAR drawn', + ); + const lastYear = (await ui()).coverage.count; + assert.ok(lastYear <= before, `${lastYear} ≤ ${before}`); + // Fixtures: the drive is a month old, the old street from 2019. + if (fixtures) + assert.ok( + lastYear > 0 && lastYear < before, + `0 < ${lastYear} < ${before}`, + ); + await setStop(0); + await settled( + (u, n) => + u.filter.sinceDays === 0 && + !u.coverage.loading && + u.coverage.count === n, + before, + 'ANY DATE: every line again', + ); + assert.equal((await ui()).coverage.error, null); + }, + ); await step( 'KartaView: a click on a line opens the nearest photo where the camera is, cones follow its drive, and › steps along it, past an archived photo', async ({ skip }) => { diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 85edd97c1..71b98ed6a 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -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, diff --git a/src/layers/streetLevel/providers/kartaview/decode.js b/src/layers/streetLevel/providers/kartaview/decode.js index b5488af00..bee93d76c 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.js +++ b/src/layers/streetLevel/providers/kartaview/decode.js @@ -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>, capturedAt: number, isPano: boolean}>, overview: Array}} + * @returns {Array>} */ -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>} segments + * @param {{x: number, y: number, z: number}} address + * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>, overview: Array}} + */ +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>} segments + * @param {{width: number, height: number, alpha: ArrayLike}} 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?.(); + } +} diff --git a/src/layers/streetLevel/providers/kartaview/decode.test.mjs b/src/layers/streetLevel/providers/kartaview/decode.test.mjs index e6cc498d6..619719008 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/decode.test.mjs @@ -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/); +}); diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index da093112b..cf932462b 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -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, viewerOptions?: object}} [options] + * @param {{source?: ReturnType, 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; }, diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 9930f2c5e..d77353d6c 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -111,6 +111,71 @@ export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]); export const KARTAVIEW_CREDIT_HTML = 'Street Level: imagery © KartaView 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} + */ +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'; diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index 0698a9e8a..fe23b8f48 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -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', + }); +}); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index ea82db277..013a510f7 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -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) => { diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js index 4283cf859..ddf21817f 100644 --- a/src/layers/streetLevel/providers/kartaview/source.js +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -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 }, ); diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index 4aed435c7..6dcd393a4 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -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); +}); diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index 11680c5d3..6a29b4f69 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; +import { inflateSync } from 'node:zlib'; import { createApplicationStreetLevel } from '../app/layers/streetLevel.js'; import { fixtureTile, @@ -24,10 +25,15 @@ import { answerKartaViewRequest, describeKartaViewCall, KV_LINE, + KV_OLD_STREET, KV_SEQUENCE_ID, kartaViewPhotos, resetKartaViewFixture, } from '../../scripts/fixtures/street-level/kartaView.mjs'; +import { + maskedSegments, + tileSegments, +} from '../layers/streetLevel/providers/kartaview/decode.js'; import { lonToTileX, latToTileY } from '../layers/streetLevel/tileMath.js'; import { normalizePhoto, @@ -743,3 +749,79 @@ test('KartaView fixtures draw the drive, answer nearest first, and refuse the st 'GET api.kartaview.org/2.0/photo/{id}', ); }); + +/** The alpha of a fixture PNG (8-bit RGBA, unfiltered rows, as it writes them). */ +function fixturePngAlpha(bytes) { + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + const idat = []; + for (let at = 8; at < bytes.length;) { + const length = bytes.readUInt32BE(at); + const type = bytes.toString('ascii', at + 4, at + 8); + if (type === 'IDAT') idat.push(bytes.subarray(at + 8, at + 8 + length)); + at += 12 + length; + } + const rows = inflateSync(Buffer.concat(idat)); + const alpha = new Uint8Array(width * height); + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) + alpha[y * width + x] = rows[y * (width * 4 + 1) + 1 + x * 4 + 3]; + return { width, height, alpha }; +} + +test('KartaView fixtures filter like KartaView: PNG tiles by view and upload date, the photo search by projection', () => { + const body = (answer) => JSON.parse(answer.body); + const z = 14; + const x = lonToTileX(KV_LINE.lon, z); + const y = latToTileY((KV_LINE.south + KV_LINE.north) / 2, z); + assert.equal(x, lonToTileX(KV_OLD_STREET.lon, z), 'both streets in a tile'); + const base = `https://api.kartaview.org/2.0/sequence/tiles/${x}/${y}/${z}`; + const segments = tileSegments( + body(answerKartaViewRequest({ method: 'GET', url: `${base}.json` })), + ); + const lons = (kept) => [...new Set(kept.map((segment) => segment[0][0]))]; + const keptFor = (query) => { + const answer = answerKartaViewRequest({ + method: 'GET', + url: `${base}.png?${query}`, + }); + assert.equal(answer.contentType, 'image/png'); + assert.equal(answer.known, true); + return lons( + maskedSegments(segments, fixturePngAlpha(answer.body), { x, y, z }), + ); + }; + assert.deepEqual( + lons(segments).sort(), + [KV_LINE.lon, KV_OLD_STREET.lon].sort(), + ); + assert.deepEqual(keptFor('fieldOfView=360'), [KV_LINE.lon]); + assert.deepEqual(keptFor('fieldOfView=plane').sort(), lons(segments).sort()); + const lastYear = new Date(Date.now() - 365 * 86_400_000) + .toISOString() + .slice(0, 10); + assert.deepEqual(keptFor(`startDate=${lastYear}`), [KV_LINE.lon]); + assert.deepEqual(keptFor('startDate=2099-01-01'), []); + assert.equal( + describeKartaViewCall('GET', new URL(`${base}.png?fieldOfView=360`)), + 'GET api.kartaview.org/2.0/sequence/tiles/{x}/{y}/{z}.png?fieldOfView', + ); + // The photo search: 360° only, and nothing uploaded after the drive. + resetKartaViewFixture(); + const search = (extra) => { + answerKartaViewRequest({ + method: 'GET', + url: 'https://api.kartaview.org/2.0/photo/?lat=0&lng=0', + }); + return body( + answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/photo/?lat=${KV_LINE.south + 0.002}&lng=${KV_LINE.lon}&zoomLevel=18&itemsPerPage=10${extra}`, + }), + ).result.data; + }; + const panos = search('&projection=SPHERE'); + assert.ok(panos.length > 0); + assert.ok(panos.every((record) => record.projection === 'SPHERE')); + assert.deepEqual(search('&startDate=2099-01-01'), []); +});