From ac9cd4ab84e8d7858aaeafcf485c3da793b7ca2e Mon Sep 17 00:00:00 2001 From: Said Date: Mon, 19 Oct 2026 23:02:46 +0100 Subject: [PATCH 1/5] fix(overpass): bound query validation and reject cross-site requests --- docs/CURRENT-STATE.md | 15 +- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 2 + server/providers/overpass.js | 20 ++ server/providers/overpass/query.js | 36 +++- .../overpass/requestAdmission.test.mjs | 178 ++++++++++++++++++ 6 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 server/providers/overpass/requestAdmission.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 60937dd05..bcc4e6fd3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2733,11 +2733,16 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > first later successful fetch. Smaller chunks reduce timeout risk; complete > camera batches still wait for their sequential requests, and unresolved > placement continues to use the existing prior until real heights arrive. -> - **Overpass cache admission:** `/api/overpass` parses and sanitizes requests, -> then checks fresh memory, identical in-flight work, and fresh disk entries -> before invoking its local 90/min limiter. Cache and single-flight responses -> therefore do not spend quota; upstream-bound misses retain the existing -> limiter, configured-upstream, stale, and sanitization behavior. An unconfigured transport serves dated stale data or a non-retryable capability error. +> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded +> POSTs through the shared local-request gate, then limits requests to 300/min +> per socket peer and 900/min globally before reading or validating bodies. +> Tag-filter brackets are scanned once; nested or unmatched brackets return 400. +> It then checks fresh memory, identical in-flight work, and fresh disk entries +> before invoking its separate local 90/min upstream limiter. Cache and +> single-flight responses therefore do not spend upstream quota; upstream-bound +> misses retain the existing limiter, configured-upstream, stale, and sanitization +> behavior. An unconfigured transport serves dated stale data or a non-retryable +> capability error. > - **CCTV world-click focus:** clicking an in-world CCTV icon or ambient card > activates it and routes the camera flight through the panel FOCUS policy. > Aircraft/satellite tracking releases outside cockpit; cockpit retains the diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 543b0f9a4..c7a95e6e3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1070,5 +1070,6 @@ "scripts/assemble-changelog.mjs", "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", - "src/terrainHeightsBounds.test.mjs" + "src/terrainHeightsBounds.test.mjs", + "server/providers/overpass/requestAdmission.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ca5dc0c7f..55effa760 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -253,6 +253,7 @@ "server/providers/common/http.js", "server/providers/common/rate-limit.js", "server/providers/common/request.js", + "server/providers/common/same-site.js", "server/providers/overpass.js", "server/providers/overpass/cache.js", "server/providers/overpass/constants.js", @@ -262,6 +263,7 @@ "server/providers/places/routes.js", "src/data/placeProviderPayloads.js", "src/data/routeSteps.js", + "src/localRequestGate.mjs", "src/sources/httpBody.js", "src/i18n/marker.js", "src/sources/overpass.js", diff --git a/server/providers/overpass.js b/server/providers/overpass.js index e6631e889..3b5a2e575 100644 --- a/server/providers/overpass.js +++ b/server/providers/overpass.js @@ -1,5 +1,6 @@ import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import { readRequestBodyCapped } from './common/request.js'; +import { admitSameSite } from './common/same-site.js'; import { OVERPASS_MAX_BODY_BYTES, OVERPASS_MAX_CONCURRENT, @@ -27,6 +28,14 @@ const _overpassInFlight = new Map(); let _overpassConcurrent = 0; +// Bound body-reading and validation work, including malformed requests and +// cache hits. Keep the tighter upstream budget below independent of this gate. +const _overpassRequestLimiter = makeRateLimiter({ + windowMs: 60_000, + max: 300, + globalMax: 900, +}); + const _overpassRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 90, @@ -94,6 +103,17 @@ function overpassProxy({ routing = {} } = {}) { return; } + if (admitSameSite(req, res)) return; + if (!_overpassRequestLimiter(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Retry-After': '60', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + // Collect POST body with a hard byte cap (Overpass QL queries are small) let body; try { diff --git a/server/providers/overpass/query.js b/server/providers/overpass/query.js index cee444d3a..4091c9efd 100644 --- a/server/providers/overpass/query.js +++ b/server/providers/overpass/query.js @@ -18,6 +18,29 @@ function isOverpassBoundaryQuery(cacheKey) { return /is_in\s*\(|\bpivot\b/i.test(String(cacheKey || '')); } +// Input has already had quoted literals and comments removed. Consume each +// character once: a regex that searches for a closing bracket from every `[` is +// quadratic on an unterminated filter. Nested or unmatched brackets are invalid. +function stripTagFilters(text) { + const parts = []; + let start = 0; + let inFilter = false; + for (let i = 0; i < text.length; i += 1) { + if (text[i] === '[') { + if (inFilter) return null; + parts.push(text.slice(start, i), ' '); + inFilter = true; + } else if (text[i] === ']') { + if (!inFilter) return null; + inFilter = false; + start = i + 1; + } + } + if (inFilter) return null; + parts.push(text.slice(start)); + return parts.join(''); +} + /** * Validate + clamp an Overpass form body. Defends the generic proxy against * planet-scale abuse: requires exactly one `data` query in which EVERY element @@ -50,6 +73,10 @@ function sanitizeOverpassBody(rawBody) { // Blank quoted literals + strip comments in one lexer pass so a fake bound or a // `//` inside a string can't hide an unbounded selector (or satisfy a bound). const stripped = stripOverpassNoise(data); + const withoutTags = stripTagFilters(stripped); + if (withoutTags === null) { + return { ok: false, error: 'Malformed Overpass tag filter' }; + } // Reject oversized radii in EVERY around form — point `around:r,lat,lon` AND the // input-set form `around.set:r` — and parse the full numeric token so scientific @@ -93,20 +120,19 @@ function sanitizeOverpassBody(rawBody) { // statement. So `way[...]->.a` (global assigned to a set) is rejected, while the // app's `is_in(...)->.a; area.a[...]` and `area(id)->.x; rel(pivot.x)` validate. const boundedSets = new Set(); - for (let stmt of stripped.split(';')) { + for (let stmt of withoutTags.split(';')) { stmt = stmt.trim(); if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue; - // Strip output-set assignments (NOT input bounds), then strip bracket tag - // filters so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) can never be + // Strip output-set assignments (NOT input bounds). Bracket tag filters were + // removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be // misread as a spatial bound. Bounds live in (...) / function calls / set // refs, never inside [...], so the probe loses nothing real. const outSets = []; - const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => { + const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => { outSets.push(name); return ' '; }); - const probe = body.replace(/\[[^\]]*\]/g, ' '); // Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter // between the selector and the area filter (way["highway"](area.a)) can't hide diff --git a/server/providers/overpass/requestAdmission.test.mjs b/server/providers/overpass/requestAdmission.test.mjs new file mode 100644 index 000000000..ecd642320 --- /dev/null +++ b/server/providers/overpass/requestAdmission.test.mjs @@ -0,0 +1,178 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Readable } from 'node:stream'; +import { spawnSync } from 'node:child_process'; +import { overpassProxy } from '../overpass.js'; +import { sanitizeOverpassBody } from './query.js'; + +const form = (query) => `data=${encodeURIComponent(query)}`; + +test('bracket validation rejects malformed filters without trusting their contents as bounds', () => { + for (const query of [ + 'node[', + 'node]', + 'node[[name]](around:20,1,1);out;', + 'node[name=around:20];out;', + 'node[is_in];out;', + 'node[name="(around:20,1,1)"];out;', + 'node(around:20,1,1);way[name];out;', + 'way[highway](area.a);out;', + 'node[->.a](around:20,1,1);way.a;out;', + ]) { + assert.equal(sanitizeOverpassBody(form(query)).ok, false, query); + } +}); + +test('bounded queries preserve quoted brackets, comments, multiple tags and timeout clamping', () => { + for (const query of [ + '[out:json][timeout:999];node[name="[Town]; ]"](around:20,1,1);out;', + '/* [ */way[highway][name](30.1,-97.9,30.5,-97.5);out geom;', + 'is_in(30.2,-97.7)->.a;area.a[boundary="administrative"];out tags;', + 'area(3600039152)->.x;rel(pivot.x);out geom;', + ]) { + const result = sanitizeOverpassBody(form(query)); + assert.equal(result.ok, true, query); + assert.equal( + new URLSearchParams(result.body).get('data'), + query.replace('[timeout:999]', '[timeout:30]'), + ); + } +}); + +test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => { + // A child deadline can stop synchronous regex backtracking; a test timeout in + // the same event loop cannot. The old parser takes several seconds for this. + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import { sanitizeOverpassBody } from ${JSON.stringify(new URL('./query.js', import.meta.url).href)}; + for (let i = 0; i < 20; i++) { + if (sanitizeOverpassBody('data=node' + '['.repeat(24000)).ok) process.exit(1); + }`, + ], + { timeout: 2000, encoding: 'utf8' }, + ); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); +}); + +function handler(hook = 'configureServer') { + let route; + overpassProxy()[hook]({ + middlewares: { + use(path, fn) { + if (path === '/api/overpass') route = fn; + }, + }, + }); + return route; +} + +async function call( + route, + { + headers = {}, + peer = '127.0.0.1', + body = 'data=node[', + forbidRead = false, + method = 'POST', + url = '/', + } = {}, +) { + let reads = 0; + const req = new Readable({ + read() { + reads++; + assert.equal(forbidRead, false, 'refused request must not read its body'); + this.push(Buffer.from(body)); + this.push(null); + }, + }); + Object.assign(req, { + method, + url, + headers: { host: 'localhost:4173', ...headers }, + socket: { remoteAddress: peer }, + }); + const res = { + statusCode: 200, + headers: {}, + setHeader(key, value) { + this.headers[key.toLowerCase()] = value; + }, + writeHead(status, values) { + this.statusCode = status; + for (const [key, value] of Object.entries(values)) + this.setHeader(key, value); + }, + end(value) { + this.body = value; + }, + }; + await route(req, res); + return { ...res, reads }; +} + +for (const hook of ['configureServer', 'configurePreviewServer']) { + test(`${hook}: cross-site requests are rejected before consuming a body or fetching`, async (t) => { + t.mock.method(globalThis, 'fetch', () => + assert.fail('unexpected upstream fetch'), + ); + const route = handler(hook); + for (const headers of [ + { origin: 'https://foreign.example', 'content-type': 'text/plain' }, + { 'sec-fetch-site': 'cross-site' }, + { 'sec-fetch-site': 'same-site' }, + { origin: 'null' }, + { 'x-forwarded-for': '127.0.0.1' }, + ]) { + const response = await call(route, { headers, forbidRead: true }); + assert.equal(response.statusCode, 403); + assert.equal(response.reads, 0); + assert.equal(response.headers['cache-control'], 'no-store'); + } + }); +} + +test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => { + const route = handler(); + for (const headers of [ + {}, + { origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' }, + { host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' }, + ]) { + const response = await call(route, { headers }); + assert.equal(response.statusCode, 400); + assert.equal(response.reads, 1); + assert.match(response.body, /Malformed Overpass tag filter/); + } + const response = await call(route, { + method: 'GET', + url: '/status', + forbidRead: true, + }); + assert.equal(response.statusCode, 200); + assert.equal(typeof JSON.parse(response.body).configured, 'boolean'); +}); + +test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => { + let now = Date.now() + 120000; + t.mock.method(Date, 'now', () => now); + t.mock.method(globalThis, 'fetch', () => + assert.fail('unexpected upstream fetch'), + ); + const route = handler(); + const peer = 'admission-limit-test'; + for (let i = 0; i < 300; i++) { + assert.equal((await call(route, { peer })).statusCode, 400); + } + const denied = await call(route, { peer, forbidRead: true }); + assert.equal(denied.statusCode, 429); + assert.equal(denied.reads, 0); + assert.equal(denied.headers['retry-after'], '60'); + assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400); + now += 60001; + assert.equal((await call(route, { peer })).statusCode, 400); +}); From c5086f8d6d1a7c155e46d792bf5491d8033d2912 Mon Sep 17 00:00:00 2001 From: Said Date: Mon, 19 Oct 2026 23:14:52 +0100 Subject: [PATCH 2/5] docs: add changelog entry for PR 1264 --- changelog.d/1264-overpass-request-admission.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1264-overpass-request-admission.md diff --git a/changelog.d/1264-overpass-request-admission.md b/changelog.d/1264-overpass-request-admission.md new file mode 100644 index 000000000..2a4794e1d --- /dev/null +++ b/changelog.d/1264-overpass-request-admission.md @@ -0,0 +1,3 @@ +- Prevent malformed Overpass filters from blocking the local server, and reject + cross-site or excessive query requests before reading their bodies + (asturksever, #1264; fixes #1248). From 84d96971df6c8279b74e6628acddc330abd11452 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 20 Oct 2026 00:15:49 +0100 Subject: [PATCH 3/5] fix: let keyless browser validation settle --- changelog.d/1264-overpass-request-admission.md | 3 +++ docs/CURRENT-STATE.md | 6 ++++++ scripts/qa-overpass-offload.mjs | 11 ++++++++++- src/maps/googleTokens.js | 5 ++++- src/maps/googleTokens.test.mjs | 17 +++++++++++++++++ 5 files changed, 40 insertions(+), 2 deletions(-) diff --git a/changelog.d/1264-overpass-request-admission.md b/changelog.d/1264-overpass-request-admission.md index 2a4794e1d..e49322df1 100644 --- a/changelog.d/1264-overpass-request-admission.md +++ b/changelog.d/1264-overpass-request-admission.md @@ -1,3 +1,6 @@ - Prevent malformed Overpass filters from blocking the local server, and reject cross-site or excessive query requests before reading their bodies (asturksever, #1264; fixes #1248). +- Release unread responses from the optional Google-token probe so keyless + browser validation can settle; the Overpass gate recognizes only that probe's + expected 404 as an unavailable capability (asturksever, #1264). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 001967ce3..f59f6c5d9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4557,6 +4557,12 @@ Retry-After; absent delays back off from 30 seconds to five minutes. Empty remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized. Consumer capability misses never schedule automatic retries. +Keyless browser validation also probes the optional Google tiles-token route. +The token client cancels rejected response bodies before returning unavailable, +so they do not remain pending in browser network observation. The Overpass gate +accepts only that same-origin route's expected 404 console diagnostic; other +HTTP and JavaScript errors still fail acceptance. + Operator-configured Overpass area and focus-footprint queries use body-level `out geom` (with `center` for focus), preserving relation member outlines. Member geometry is printed only inside a box around the point (6 km for diff --git a/scripts/qa-overpass-offload.mjs b/scripts/qa-overpass-offload.mjs index e04a05b86..8f6594011 100644 --- a/scripts/qa-overpass-offload.mjs +++ b/scripts/qa-overpass-offload.mjs @@ -306,7 +306,16 @@ try { await observeNetwork(page); page.on('pageerror', (error) => result.errors.push(error.message)); page.on('console', (message) => { - if (message.type() === 'error' && result.consoleErrors.length < 30) + if (message.type() !== 'error') return; + // A keyless server does not offer optional Google tokens. Keep every other + // HTTP or JavaScript error visible, including failures of this route other + // than its expected capability-miss 404. + const optionalTokenMiss = + message.location().url === new URL('/api/google/tiles-token', url).href && + /^Failed to load resource: the server responded with a status of 404\b/.test( + message.text(), + ); + if (!optionalTokenMiss && result.consoleErrors.length < 30) result.consoleErrors.push(message.text()); }); page.on('requestfailed', (request) => { diff --git a/src/maps/googleTokens.js b/src/maps/googleTokens.js index 7896c8f5c..884c5b199 100644 --- a/src/maps/googleTokens.js +++ b/src/maps/googleTokens.js @@ -38,7 +38,10 @@ export function createGoogleTokenSource({ headers: { Accept: 'application/json' }, cache: 'no-store', }); - if (!response.ok) return null; + if (!response.ok) { + await response.body?.cancel(); + return null; + } return parseToken(await response.json(), now()); } catch { return null; diff --git a/src/maps/googleTokens.test.mjs b/src/maps/googleTokens.test.mjs index 87b7cf8dc..d8a1e84ed 100644 --- a/src/maps/googleTokens.test.mjs +++ b/src/maps/googleTokens.test.mjs @@ -50,6 +50,23 @@ test('requests refused for the same token renew it once', async () => { assert.equal(calls.length, 2); }); +test('a refused token response releases its unread body before returning', async () => { + for (const status of [404, 429, 500]) { + let cancelled = false; + const response = new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { status }, + ); + const tokens = createGoogleTokenSource({ fetchImpl: async () => response }); + assert.equal(await tokens.token(), null); + assert.equal(cancelled, true, `HTTP ${status} body released`); + } +}); + test('a server that does not offer tokens yields none', async () => { for (const answer of [ new Response('Not found', { status: 404 }), From 69a87fe23807a3132b58e653ed947b1024c63083 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 20 Oct 2026 00:44:51 +0100 Subject: [PATCH 4/5] test: observe aircraft arrival before idle refresh --- .../1264-overpass-request-admission.md | 3 + docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 3 +- scripts/track-regression.mjs | 85 ++++------ src/tooling/trackArrivalObservation.mjs | 100 ++++++++++++ src/tooling/trackArrivalObservation.test.mjs | 151 ++++++++++++++++++ 6 files changed, 291 insertions(+), 53 deletions(-) create mode 100644 src/tooling/trackArrivalObservation.mjs create mode 100644 src/tooling/trackArrivalObservation.test.mjs diff --git a/changelog.d/1264-overpass-request-admission.md b/changelog.d/1264-overpass-request-admission.md index e49322df1..c5e763e30 100644 --- a/changelog.d/1264-overpass-request-admission.md +++ b/changelog.d/1264-overpass-request-admission.md @@ -4,3 +4,6 @@ - Release unread responses from the optional Google-token probe so keyless browser validation can settle; the Overpass gate recognizes only that probe's expected 404 as an unavailable capability (asturksever, #1264). +- Make tracking arrival checks respect the aircraft update cadence while checking + orientation on the first reveal, avoiding false failures at faster render + rates (asturksever, #1264). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f59f6c5d9..16817cd44 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5011,7 +5011,7 @@ pins and share links already supply complete boxes. - Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker. - Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere. - Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks. -- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers. +- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers. ### TR-3B conversion Easter egg (August 2026) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c7a95e6e3..690a33f3e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1071,5 +1071,6 @@ "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", "src/terrainHeightsBounds.test.mjs", - "server/providers/overpass/requestAdmission.test.mjs" + "server/providers/overpass/requestAdmission.test.mjs", + "src/tooling/trackArrivalObservation.test.mjs" ] diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index 756daccfc..044fdf74f 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -94,6 +94,7 @@ import fs from 'node:fs'; import puppeteer from 'puppeteer'; +import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs'; import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js'; import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js'; @@ -2695,16 +2696,17 @@ async function main() { // reappear wearing its stale/creation-north nose for up to 1 s). // Staged deterministically: park the camera nadir over the synthetic // cluster, let rotations settle, TAMPER a billboard's rotation, then - // trigger each path with the camera idle. The idle-drift catch-up - // (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a - // ~300 ms SwiftShader frame window, so a regression shows as an - // intermittent (not guaranteed) failure — never a false failure. + // trigger each path with the camera idle. The fleet runs every 80 ms, + // independently of rendered FPS. Observe the first completed settle and + // reveal frames within 500 ms of forcing moveEnd, before the 1 s idle + // rotation refresh can repair an incorrect first reveal. // ============================================================ console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass'); const arrival = await evalPage(async () => { const v = window.__godsEyeView.viewer; const dm = window.__godsEyeView.dataManager; const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`); + const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`); // 3D models OFF for this phase: a model-handed-off billboard is hidden // and skips rotation updates entirely — the probes need live billboards // (this is also the app's default state the field report came from). @@ -2766,39 +2768,29 @@ async function main() { const bb = findBB(id); if (!bb || !bb.show) return { error: `${id} missing/hidden` }; const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below - // (a) settle pass: tamper, then raise moveEnd with the camera IDLE — - // the pose signature is unchanged, so only the moveEnd hook can fix - // this before the 1 s catch-up. - bb.rotation = r0 + 1.5; - v.camera.moveEnd.raiseEvent(); - await nextFrames(2); - const afterMoveEnd = bb.rotation; - // A real-GPU fleet tick can land near the edge of the frame budget and - // advance the contact before the probe reads it. Compare with the - // production projection at the CURRENT position, not the now-stale r0. - const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null); - // (b) reveal pass: tamper + hide — the next fleet tick must flip it - // visible AND correct the nose in that same tick (camera still idle, - // no moveEnd raised). Diagnostic fields (round 5): record WHICH frame - // the flip landed on (flipFrame; -1 = never within the extended - // window) and whether a 3D model owns this id — distinguishes "tick - // slow under load" from "occluder says hidden" from "model handoff". - bb.rotation = r0 + 1.5; - bb.show = false; - let flipFrame = -1; - for (let f = 1; f <= 6; f++) { - await nextFrames(1); - if (bb.show && flipFrame === -1) flipFrame = f; - if (f >= 2 && flipFrame !== -1) break; - } - const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null); + const observed = await observeArrivalRotation({ + scene: v.scene, + startMoveEnd() { + // Only moveEnd can repair the tampered angle at the idle pose. + bb.rotation = r0 + 1.5; + v.camera.moveEnd.raiseEvent(); + }, + startReveal() { + // Hide immediately after the confirmed settle frame. The next + // eligible fleet tick must reveal AND re-aim in that same tick. + bb.rotation = r0 + 1.5; + bb.show = false; + }, + read() { + return { + shown: bb.show, + rotationError: angDiff(bb.rotation, + screenProjectedRotation(v.scene, bb.position, course, null)), + }; + }, + }); const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id)); - return { - r0, - dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd), - dReveal: angDiff(bb.rotation, expectedReveal), - shown: bb.show, flipFrame, hasModel, - }; + return { r0, ...observed, hasModel }; }; const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0; const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0; @@ -2807,22 +2799,13 @@ async function main() { military: await probe('bbb101', militaryCourse), }; }); - const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error - : `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`); + const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms` + : `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`); const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off - // Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe - // shows the military flip consistently lands on frame 2 — the exact edge - // of the old budget — so any load hiccup flaked it (flights lands frame - // 1). The regression this protects against is a reveal that waits for - // the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still - // catches that with a wide margin, with zero-headroom flakes gone. - const arrOk = (a) => !!a && !a.error && a.shown - && (a.flipFrame === -1 ? false : a.flipFrame <= 4) - && a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10; - record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`, - arrOk(arrival.flights), fmtArr(arrival.flights)); - record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`, - arrOk(arrival.military), fmtArr(arrival.military)); + record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`, + arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights)); + record(`arrival: moveEnd + first reveal both re-aim before idle refresh, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`, + arrivalRotationPassed(arrival.military), fmtArr(arrival.military)); // ============================================================ // DISPLAY FLOOR (2026-08-19): a grounded contact must never render below diff --git a/src/tooling/trackArrivalObservation.mjs b/src/tooling/trackArrivalObservation.mjs new file mode 100644 index 000000000..370624891 --- /dev/null +++ b/src/tooling/trackArrivalObservation.mjs @@ -0,0 +1,100 @@ +/** A reveal must beat the one-second idle rotation refresh, regardless of FPS. */ +export const ARRIVAL_OBSERVATION_MS = 500; + +/** + * Observe the first completed moveEnd frame and the first subsequent reveal. + * The deadline starts when moveEnd is forced, before its rotation baseline, so + * waiting for either frame cannot let the one-second idle refresh mask a bug. + * `read` snapshots visibility and rotation error against the current position. + */ +export function observeArrivalRotation({ + scene, + startMoveEnd, + startReveal, + read, + now = () => performance.now(), + setTimer = setTimeout, + clearTimer = clearTimeout, +}) { + return new Promise((resolve, reject) => { + const startedAt = now(); + let moveEnd = null; + let moveEndMs = null; + let revealFrames = 0; + let done = false; + let remove; + let timer; + const cleanup = () => { + done = true; + remove?.(); + clearTimer(timer); + }; + const finish = (result) => { + if (done) return; + cleanup(); + resolve(result); + }; + const expired = () => ({ + error: `arrival ${moveEnd ? 'reveal' : 'moveEnd'} observation exceeded ${ARRIVAL_OBSERVATION_MS} ms`, + elapsedMs: now() - startedAt, + moveEndMs, + flipFrame: -1, + }); + remove = scene.postRender.addEventListener(() => { + if (done) return; + try { + const elapsedMs = now() - startedAt; + if (elapsedMs >= ARRIVAL_OBSERVATION_MS) { + finish(expired()); + return; + } + const sample = read(); + if (moveEnd === null) { + moveEnd = sample; + moveEndMs = elapsedMs; + startReveal(); + } else { + revealFrames++; + if (sample.shown) { + // Keep the FIRST visible result, including a wrong angle. Never + // wait for a subsequent tick to repair a failed reveal. + finish({ + dMoveEnd: moveEnd.rotationError, + dReveal: sample.rotationError, + shown: sample.shown, + flipFrame: revealFrames, + moveEndMs, + revealMs: elapsedMs - moveEndMs, + elapsedMs, + }); + return; + } + } + scene.requestRender(); + } catch (error) { + cleanup(); + reject(error); + } + }); + timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS); + try { + startMoveEnd(); + scene.requestRender(); + } catch (error) { + cleanup(); + reject(error); + } + }); +} + +/** Test the captured frames without retrying a bad first observation. */ +export function arrivalRotationPassed(observation) { + return ( + !!observation && + !observation.error && + observation.shown === true && + observation.elapsedMs < ARRIVAL_OBSERVATION_MS && + (observation.dMoveEnd * 180) / Math.PI < 10 && + (observation.dReveal * 180) / Math.PI < 10 + ); +} diff --git a/src/tooling/trackArrivalObservation.test.mjs b/src/tooling/trackArrivalObservation.test.mjs new file mode 100644 index 000000000..7c140bf37 --- /dev/null +++ b/src/tooling/trackArrivalObservation.test.mjs @@ -0,0 +1,151 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + ARRIVAL_OBSERVATION_MS, + arrivalRotationPassed, + observeArrivalRotation, +} from './trackArrivalObservation.mjs'; + +function fixture() { + let time = 0; + let state = { shown: true, rotationError: 0 }; + const listeners = new Set(); + const timers = new Map(); + let renders = 0; + let moveEnds = 0; + const observation = observeArrivalRotation({ + scene: { + postRender: { + addEventListener(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + requestRender() { + renders++; + }, + }, + startMoveEnd() { + moveEnds++; + state.rotationError = 1.5; + }, + startReveal() { + state = { shown: false, rotationError: 1.5 }; + }, + read: () => ({ ...state }), + now: () => time, + setTimer(callback, delay) { + const handle = {}; + timers.set(handle, { callback, at: time + delay }); + return handle; + }, + clearTimer(handle) { + timers.delete(handle); + }, + }); + return { + observation, + frame(at, nextState) { + time = at; + state = { ...state, ...nextState }; + for (const listener of [...listeners]) listener(); + }, + expire(at) { + time = at; + for (const timer of [...timers.values()]) + if (timer.at <= at) timer.callback(); + }, + get resources() { + return { + listeners: listeners.size, + timers: timers.size, + renders, + moveEnds, + }; + }, + }; +} + +test('80 ms fleet scheduling passes after more than four fast rendered frames', async () => { + const f = fixture(); + f.frame(8, { shown: true, rotationError: 0 }); + // The forced fleet tick was at 8 ms. A 125 FPS renderer can complete nine + // more frames before the next 80 ms fleet interval becomes eligible. + for (let at = 16; at < 88; at += 8) f.frame(at); + f.frame(88, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.equal(result.flipFrame, 10); + assert.equal(result.moveEndMs, 8); + assert.equal(result.revealMs, 80); + assert.equal(result.elapsedMs, 88); + assert.equal(arrivalRotationPassed(result), true); + assert.equal(f.resources.listeners, 0); + assert.equal(f.resources.timers, 0); + assert.equal(f.resources.moveEnds, 1); +}); + +test('a wrong first visible angle fails even if the next frame corrects it', async () => { + const f = fixture(); + f.frame(8, { shown: true, rotationError: 0 }); + f.frame(88, { shown: true, rotationError: 1.5 }); + f.frame(96, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.equal(result.dReveal, 1.5); + assert.equal(result.flipFrame, 1); + assert.equal(result.elapsedMs, 88); + assert.equal(arrivalRotationPassed(result), false); +}); + +test('a wrong first moveEnd angle is retained rather than repaired by reveal', async () => { + const f = fixture(); + f.frame(8, { shown: true, rotationError: 1.5 }); + f.frame(88, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.equal(result.dMoveEnd, 1.5); + assert.equal(arrivalRotationPassed(result), false); +}); + +test('no reveal fails at a deadline before the one-second idle catch-up', async () => { + const f = fixture(); + assert.equal(ARRIVAL_OBSERVATION_MS, 500); + f.frame(8, { shown: true, rotationError: 0 }); + f.frame(480); + f.expire(500); + const result = await f.observation; + assert.match(result.error, /reveal.*500 ms/); + assert.equal(arrivalRotationPassed(result), false); + assert.equal(f.resources.listeners, 0); + assert.equal(f.resources.timers, 0); + f.frame(1000, { shown: true, rotationError: 0 }); + assert.equal(result.flipFrame, -1); +}); + +test('a late visible frame fails even if the timeout callback is delayed', async () => { + const f = fixture(); + f.frame(8, { shown: true, rotationError: 0 }); + f.frame(501, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.match(result.error, /reveal.*500 ms/); + assert.equal(result.elapsedMs, 501); + assert.equal(arrivalRotationPassed(result), false); +}); + +test('the deadline includes moveEnd setup rather than restarting when hidden', async () => { + const f = fixture(); + f.frame(400, { shown: true, rotationError: 0 }); + f.frame(600, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.equal(result.moveEndMs, 400); + assert.equal(result.elapsedMs, 600); + assert.equal(arrivalRotationPassed(result), false); +}); + +test('missing moveEnd frames fail without waiting for a render callback', async () => { + const f = fixture(); + f.expire(500); + const result = await f.observation; + assert.match(result.error, /moveEnd.*500 ms/); + assert.equal(arrivalRotationPassed(result), false); + assert.equal(f.resources.listeners, 0); + assert.equal(f.resources.timers, 0); +}); From f6006358edc417781338de15803668928ca03f35 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 20 Oct 2026 13:23:44 +0100 Subject: [PATCH 5/5] fix(street-level): SINCE note on the value's line: ANY DATE (by upload date) The slim-down in #974 dropped --sl-text-small, which .sl-since-note still used, so with KartaView lit "KartaView: by upload date" rendered on its own line larger than the value. The note now sits beside the value in the same mono type and letter spacing, a size smaller (0.5rem), as "(by upload date)". One source is lit at a time, so the provider name is dropped. es/ru catalogs, tests, README, CURRENT-STATE and the registry JSDoc follow. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- changelog.d/1286-since-note-inline.md | 4 ++++ docs/CURRENT-STATE.md | 4 ++-- src/i18n/locales/es.json | 2 +- src/i18n/locales/ru.json | 2 +- .../streetLevel/providers/kartaview/policy.js | 2 +- src/layers/streetLevel/registry.js | 2 +- src/layers/streetLevel/uiState.js | 12 +++++------- src/layers/streetLevel/uiState.test.mjs | 4 ++-- src/ui/streetLevelControls.test.mjs | 4 ++-- src/ui/styles/street-level.css | 15 ++++++++++++--- src/ui/templates/layer-panels.html | 6 ++++-- 12 files changed, 36 insertions(+), 23 deletions(-) create mode 100644 changelog.d/1286-since-note-inline.md diff --git a/README.md b/README.md index affe39fa3..a38fc893b 100644 --- a/README.md +++ b/README.md @@ -425,7 +425,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** and **Panoramax** need nothing: their public APIs are 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. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). 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** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). 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", "© Panoramax contributors, licence per picture"). 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. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). 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** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). 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", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. 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*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work 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/changelog.d/1286-since-note-inline.md b/changelog.d/1286-since-note-inline.md new file mode 100644 index 000000000..f66467d67 --- /dev/null +++ b/changelog.d/1286-since-note-inline.md @@ -0,0 +1,4 @@ +- The Street Level SINCE note sits on the value's line, in its type and a + size smaller: with KartaView lit it reads ANY DATE (by upload date). It + had lost its size variable in the Street Level slim-down and rendered + larger than the value, on a line of its own (asturksever, #1286). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 42f338ce5..83645c1fa 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3648,8 +3648,8 @@ lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores `fieldOfView`) and upload date and checks each answer the same way (when photos are near but none passes, the panel says so), and the cones pass on each photo's projection and upload date. The provider declares -`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by -upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and +`sinceBasis: 'upload'`, so while it is lit the panel shows "(by +upload date)" beside the SINCE value, as ANY DATE (by upload date) (`sinceNote` in the UI state). Tiles come uncompressed and uncacheable, so the last 24 decoded tiles are kept for 10 minutes while KartaView is lit (`createTileCache`, dropped when it is switched off); one that does not answer in 20 s is asked once more, and one that is not a line diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d86ab843d..76ed72b3d 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -945,7 +945,7 @@ "Image by {creator}": "Imagen de {creator}", "zoom in to see KartaView coverage": "acércate para ver la cobertura de KartaView", "no KartaView photos in view": "no hay fotos de KartaView a la vista", - "{names}: by upload date": "{names}: por fecha de subida", + "(by upload date)": "(por fecha de subida)", "{n} streets in view · click a line for its nearest photo": { "one": "{n} calle a la vista · haz clic en una línea para su foto más cercana", "many": "{n} calles a la vista · haz clic en una línea para su foto más cercana", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7ee31fd1c..81cd13b55 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -954,7 +954,7 @@ "Image by {creator}": "Автор снимка: {creator}", "zoom in to see KartaView coverage": "приблизьтесь, чтобы увидеть покрытие KartaView", "no KartaView photos in view": "фотографий KartaView в зоне видимости нет", - "{names}: by upload date": "{names}: по дате загрузки", + "(by upload date)": "(по дате загрузки)", "{n} streets in view · click a line for its nearest photo": { "one": "{n} улица в кадре · щёлкните линию, чтобы открыть ближайшую фотографию", "few": "{n} улицы в кадре · щёлкните линию, чтобы открыть ближайшую фотографию", diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 184d1ceb8..939e0ec1c 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -141,7 +141,7 @@ export const FILTERED_OUT_MESSAGE = msg( 'None of KartaView’s photos here pass the imagery filter', ); /** Under the SINCE slider while KartaView is lit. */ -export const SINCE_NOTE = 'KartaView: by upload date'; +export const SINCE_NOTE = '(by upload date)'; /** The UTC calendar day of `ms`, as KartaView's startDate reads it. */ function utcDay(ms) { diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index ba0f986f2..6770cd133 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -23,7 +23,7 @@ export function filtersOf(provider) { * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). - * @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit. + * @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' notes "(by upload date)" beside the SINCE value while the provider is lit. * @property {(context: ProviderContext) => ProviderInstance} create * * @typedef {object} ProviderContext Handed to `create()` once by the core. diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index efb0dd750..577bb8ae1 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -73,16 +73,14 @@ export function filterControls(providers) { } /** - * The note under SINCE when a lit provider dates its imagery by upload, - * e.g. "KartaView: by upload date", or null. + * The note beside the SINCE value when a lit provider dates its imagery by + * upload, "(by upload date)", or null. One source is lit at a time, so the + * note needs no provider name. * @param {Array} providers */ export function sinceNote(providers) { - const names = providers - .filter((p) => p.on && p.sinceBasis === 'upload') - .map((p) => p.name); - return names.length - ? t('{names}: by upload date', { names: names.join(', ') }) + return providers.some((p) => p.on && p.sinceBasis === 'upload') + ? t('(by upload date)') : null; } diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index 5b9b0bf41..009600026 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -67,7 +67,7 @@ test('the filter controls show only for a lit provider that honours them', () => ); }); -test('a lit provider that dates by upload puts a note under SINCE', () => { +test('a lit provider that dates by upload notes it beside the SINCE value', () => { const kartaView = provider({ id: 'kartaview', name: 'KartaView', @@ -75,7 +75,7 @@ test('a lit provider that dates by upload puts a note under SINCE', () => { }); const note = (providers) => composeUIState({ ...base, providers }).sinceNote; assert.equal(note([provider()]), null, 'Mapillary: capture date'); - assert.equal(note([kartaView]), 'KartaView: by upload date'); + assert.equal(note([kartaView]), '(by upload date)'); assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit'); }); diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index adeaf2ae4..6f7d35688 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -981,9 +981,9 @@ test('a note under SINCE says what date it compares, only while one is given', ( const { layer } = stubPanel(dom, state); const note = () => dom.root.querySelector('#sl-since-note'); assert.equal(note().hidden, true, 'none for Mapillary'); - layer.publish({ ...state, sinceNote: 'KartaView: by upload date' }); + layer.publish({ ...state, sinceNote: '(by upload date)' }); assert.equal(note().hidden, false); - assert.equal(note().textContent, 'KartaView: by upload date'); + assert.equal(note().textContent, '(by upload date)'); layer.publish(state); assert.equal(note().hidden, true); // The panel's own markup has it, read out with the slider. diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index c218765d9..e14f492de 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -291,10 +291,20 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn { outline-offset: 3px; } -.sl-since-note { +/* The value and its note share a line: ANY DATE (by upload date). */ +.sl-since-value { grid-column: 2; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0 6px; + min-width: 0; +} + +.sl-since-note { font-family: var(--font-mono); - font-size: var(--sl-text-small); + font-size: 0.5rem; + letter-spacing: 0.08em; color: var(--text-secondary); } @@ -303,7 +313,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn { } .sl-since-label { - grid-column: 2; font-family: var(--font-mono); font-size: var(--sl-text); letter-spacing: 0.08em; diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index b170b01e3..cbc8f6e67 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -127,8 +127,10 @@
- ANY DATE - + + ANY DATE + +