From 2b0fcd4595049d207c262a32b41044eaf500b5f6 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:08:12 -0500 Subject: [PATCH 01/29] fix(bikeshare): address the GBFS proxy by path, not query string (#569) Supersedes #441 by @MiguelGFerreira, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: MiguelGFerreira --- CHANGELOG.md | 5 + docs/CURRENT-STATE.md | 4 +- scripts/format-scope.json | 1 + src/layers/bikeshare/proxyRoute.test.mjs | 300 +++++++++++++++++++++++ src/layers/bikeshare/source.js | 2 +- src/layers/bikeshare/source.test.mjs | 4 +- 6 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 src/layers/bikeshare/proxyRoute.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c18e8da84..8c8b828b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,11 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Fixed +- Bikeshare stations load again. The extracted station source addressed the + proxy as `/api/gbfs?url=`, but the proxy reads its upstream target from the + path, so every request answered 400 and the layer reported a fetch error for + every city (#441 — thanks @MiguelGFerreira). + - Draped annotation geometry — area fills and outlines, routes and arrows — classifies onto terrain as well as 3D tiles. On a keyless boot, where Cesium's own globe carries the imagery, marks previously rendered their labels and no diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ee9ee6015..fde4d4c59 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -466,7 +466,9 @@ The existing declared media size ceiling remains 64 MiB. ## GBFS upstream bounds -GBFS refuses upstream redirects and enforces its 5 MiB response cap while +The station reader addresses the proxy as `/api/gbfs/`; the +feed address is read from the path, and a query-string form is refused with a +400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while streaming. The 12-second deadline includes reading the body, and rejected or stalled downloads are cancelled. Development and preview use the same handler. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1b6d251be..8832b5ff7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -440,6 +440,7 @@ "src/data/flowTiles.js", "src/layers/traffic/source.test.mjs", "src/layers/bikeshare/source.test.mjs", + "src/layers/bikeshare/proxyRoute.test.mjs", "scripts/traffic-fixtures.mjs", "src/layers/traffic/navigation.test.mjs", "scripts/qa-traffic-navigation.mjs", diff --git a/src/layers/bikeshare/proxyRoute.test.mjs b/src/layers/bikeshare/proxyRoute.test.mjs new file mode 100644 index 000000000..88b98f31b --- /dev/null +++ b/src/layers/bikeshare/proxyRoute.test.mjs @@ -0,0 +1,300 @@ +// BIKESHARE PROXY ROUTE — the client's URL against the real mounted middleware. +// +// The station source and the GBFS proxy are unit-tested apart, each against a +// stand-in for the other, so a disagreement about the request shape stayed +// invisible while the layer was dark. These cases run the URL the client +// actually produces through the middleware the dev server actually mounts, with +// only the upstream network call replaced, and assert the two documents join +// into a station record with availability counts. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { gbfsProxy } from '../../../server/providers/gbfs.js'; +import { localProviderPlugins } from '../../../server/providers/local.js'; +import { createBikeshareSource } from './source.js'; +import { createModel } from './model.js'; +import { CITY_BY_ID } from './registry.js'; + +const AUSTIN = CITY_BY_ID.get('austin-capmetro'); + +const STATION_INFORMATION = { + data: { + stations: [ + { + station_id: '2537', + name: 'Republic Square', + lat: 30.2681, + lon: -97.7469, + capacity: 13, + }, + ], + }, +}; + +const STATION_STATUS = { + data: { + stations: [ + { + station_id: '2537', + num_bikes_available: 4, + num_docks_available: 9, + is_renting: 1, + is_returning: 1, + }, + ], + }, +}; + +/** + * Mount the GBFS proxy the way the dev server does and return a fetch-shaped + * entry point for it. + * + * `server.middlewares.use(route, handler)` is connect: it matches a request + * whose path starts with the mount route, then strips that prefix from + * `req.url` before the handler sees it. The handler therefore never reads + * `/api/gbfs/...`, only the remainder — which is exactly the detail the client + * and the proxy disagreed about. Reproducing the strip here is what makes this + * a round trip rather than two more isolated stubs. + * + * @returns {(input: string, init?: object) => Promise} + */ +function mountGbfsProxy( + install = (server) => gbfsProxy().configureServer(server), +) { + const routes = new Map(); + install({ + middlewares: { + use(route, handler) { + routes.set(route, handler); + }, + }, + }); + + return async function mountedFetch(input, init = {}) { + const requested = new URL(String(input), 'http://localhost'); + let matched = null; + for (const [route, handler] of routes) { + if ( + requested.pathname !== route && + !requested.pathname.startsWith(route + '/') + ) + continue; + matched = { route, handler }; + break; + } + if (!matched) return new Response('no route', { status: 404 }); + + // connect: the mount prefix is removed and a leading slash restored. + let rest = + requested.pathname.slice(matched.route.length) + requested.search; + if (rest[0] !== '/') rest = '/' + rest; + + const req = { method: init.method || 'GET', url: rest, headers: {} }; + return await new Promise((resolve, reject) => { + let status = 200; + let headers = {}; + const res = { + writeHead(code, sent) { + status = code; + headers = sent || {}; + return res; + }, + end(body = '') { + resolve(new Response(String(body), { status, headers })); + }, + }; + Promise.resolve(matched.handler(req, res)).catch(reject); + }); + }; +} + +/** Replace the global fetch used by the proxy's upstream step for one call. */ +async function withUpstream(byUrl, run) { + const previous = globalThis.fetch; + const requested = []; + globalThis.fetch = async (url) => { + const href = String(url); + requested.push(href); + const body = byUrl[href]; + if (!body) return new Response('not found', { status: 404 }); + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + try { + return await run(requested); + } finally { + globalThis.fetch = previous; + } +} + +test('the station source reaches the mounted proxy and both documents join into a station record', async () => { + const upstreamByUrl = { + [AUSTIN.stationInformationUrl]: STATION_INFORMATION, + [AUSTIN.stationStatusUrl]: STATION_STATUS, + }; + + const { info, status, requested } = await withUpstream( + upstreamByUrl, + async (requested) => { + const source = createBikeshareSource({ + fetchImpl: mountGbfsProxy(), + }); + const info = await source.getStations(AUSTIN.stationInformationUrl); + const status = await source.getStations(AUSTIN.stationStatusUrl); + return { info, status, requested }; + }, + ); + + assert.deepEqual( + requested, + [AUSTIN.stationInformationUrl, AUSTIN.stationStatusUrl], + 'the proxy reached the upstream the client asked for, unchanged', + ); + + const model = createModel({}); + const infoMap = model.parseStationInformation(info); + const statusMap = model.parseStationStatus(status); + const station = infoMap.get('2537'); + const availability = statusMap.get('2537'); + assert.ok(station, 'station information survived the round trip'); + assert.ok(availability, 'station status survived the round trip'); + + assert.deepEqual( + { + stationId: station.stationId, + name: station.name, + lat: station.lat, + lon: station.lon, + capacity: station.capacity, + bikesAvailable: availability.bikesAvailable, + docksAvailable: availability.docksAvailable, + isRenting: availability.isRenting, + }, + { + stationId: '2537', + name: 'Republic Square', + lat: 30.2681, + lon: -97.7469, + capacity: 13, + bikesAvailable: 4, + docksAvailable: 9, + isRenting: true, + }, + ); +}); + +test('the proxy relays its cache policy and upstream host through the mounted route', async () => { + await withUpstream( + { [AUSTIN.stationStatusUrl]: STATION_STATUS }, + async () => { + const mounted = mountGbfsProxy(); + const response = await mounted( + '/api/gbfs/' + encodeURIComponent(AUSTIN.stationStatusUrl), + ); + assert.equal(response.status, 200); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal( + response.headers.get('x-gbfs-upstream'), + 'austin.publicbikesystem.net', + ); + }, + ); +}); + +test('the query-string shape the client used to send is refused by the mounted route', async () => { + await withUpstream( + { [AUSTIN.stationStatusUrl]: STATION_STATUS }, + async (requested) => { + const mounted = mountGbfsProxy(); + const response = await mounted( + '/api/gbfs?url=' + encodeURIComponent(AUSTIN.stationStatusUrl), + ); + assert.equal( + response.status, + 400, + 'the old client URL is the 400 every city reported', + ); + assert.deepEqual(requested, [], 'no upstream request is made for it'); + }, + ); +}); + +test('the mounted route still refuses a host outside the allowlist', async () => { + await withUpstream({}, async (requested) => { + const mounted = mountGbfsProxy(); + const response = await mounted( + '/api/gbfs/' + + encodeURIComponent('https://attacker.example/station_status.json'), + ); + assert.equal(response.status, 403); + assert.deepEqual(requested, []); + }); +}); + +/** + * The GBFS plugin as the application actually composes it. + * + * Mounting `gbfsProxy()` directly proves the handler agrees with the client, + * but not that the application still registers it. These take the plugin out + * of the real provider list by name, so dropping GBFS from the composition — + * or from the preview half of it — fails here rather than in a browser. + */ +function composedGbfsPlugin() { + const plugin = localProviderPlugins().find( + (entry) => entry.name === 'gbfs-proxy', + ); + assert.ok(plugin, 'the application must compose a GBFS provider'); + return plugin; +} + +for (const hook of ['configureServer', 'configurePreviewServer']) { + test(`the composed application serves the client's GBFS URL through ${hook}`, async () => { + const plugin = composedGbfsPlugin(); + assert.equal( + typeof plugin[hook], + 'function', + `the composed GBFS provider must register on ${hook}`, + ); + + const { info, status, requested } = await withUpstream( + { + [AUSTIN.stationInformationUrl]: STATION_INFORMATION, + [AUSTIN.stationStatusUrl]: STATION_STATUS, + }, + async (requested) => { + const source = createBikeshareSource({ + fetchImpl: mountGbfsProxy((server) => plugin[hook](server)), + }); + const info = await source.getStations(AUSTIN.stationInformationUrl); + const status = await source.getStations(AUSTIN.stationStatusUrl); + return { info, status, requested }; + }, + ); + + assert.deepEqual(requested, [ + AUSTIN.stationInformationUrl, + AUSTIN.stationStatusUrl, + ]); + + const model = createModel({}); + const station = model.parseStationInformation(info).get('2537'); + const availability = model.parseStationStatus(status).get('2537'); + assert.deepEqual( + { + name: station?.name, + capacity: station?.capacity, + bikesAvailable: availability?.bikesAvailable, + docksAvailable: availability?.docksAvailable, + }, + { + name: 'Republic Square', + capacity: 13, + bikesAvailable: 4, + docksAvailable: 9, + }, + ); + }); +} diff --git a/src/layers/bikeshare/source.js b/src/layers/bikeshare/source.js index effcb9a57..23ef6a89f 100644 --- a/src/layers/bikeshare/source.js +++ b/src/layers/bikeshare/source.js @@ -9,7 +9,7 @@ export function createBikeshareSource({ throw new TypeError('A public HTTPS GBFS URL is required'); signal?.throwIfAborted(); const response = await fetchImpl( - '/api/gbfs?url=' + encodeURIComponent(url.href), + '/api/gbfs/' + encodeURIComponent(url.href), { method: 'GET', headers: { Accept: 'application/json' }, signal }, ); if (!response.ok) throw new Error('GBFS HTTP ' + response.status); diff --git a/src/layers/bikeshare/source.test.mjs b/src/layers/bikeshare/source.test.mjs index cee7e2ab1..6cf4157d0 100644 --- a/src/layers/bikeshare/source.test.mjs +++ b/src/layers/bikeshare/source.test.mjs @@ -18,9 +18,9 @@ test('station source keeps upstream URLs behind the fixed GBFS endpoint', async assert.equal(calls.length, 0); await source.getStations('https://example.test/stations.json'); const url = new URL(calls[0][0], 'https://app.example'); - assert.equal(url.pathname, '/api/gbfs'); + assert.equal(url.search, ''); assert.equal( - url.searchParams.get('url'), + decodeURIComponent(url.pathname.replace(/^\/api\/gbfs\//, '')), 'https://example.test/stations.json', ); }); From fb5ee5aa36272f6463385f9ebe1e5085bd0164e6 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:11:32 -0500 Subject: [PATCH 02/29] fix(installations): send an identifying User-Agent to Overpass (#570) Supersedes #420 by @GladiatorrX9, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: Cyber Knight --- CHANGELOG.md | 7 +++ docs/CURRENT-STATE.md | 1 + server/providers/overpass/constants.js | 14 +++++ server/providers/overpass/transport.js | 14 ++--- src/overpassProxy.test.mjs | 83 ++++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8b828b2..168d709cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md proxy as `/api/gbfs?url=`, but the proxy reads its upstream target from the path, so every request answered 400 and the layer reported a fetch error for every city (#441 — thanks @MiguelGFerreira). +- Overpass requests now carry a User-Agent that names the application, its + version and the project address, which is what the OpenStreetMap API usage + policy asks for; the previous string identified neither. A mirror may refuse + a client it cannot identify, and a refused mirror is one the fan-out has to + skip, so this affects every Overpass-backed layer: Mapped Installations, + traffic roads and annotation geometry. Mirror rotation, cooldown and cache + admission are unchanged (#420 — thanks @GladiatorrX9). - Draped annotation geometry — area fills and outlines, routes and arrows — classifies onto terrain as well as 3D tiles. On a keyless boot, where Cesium's diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fde4d4c59..4305e0356 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2884,6 +2884,7 @@ silently demoting every later lookup for the session. - `/api/overpass` fans out across four public mirrors. `overpassPayloadIsData()` governs cache reads, writes, and stale fallback: only a 2xx that is neither rate-limited nor a body-level runtime error qualifies. Previously stored refusals are ignored on both fresh and stale reads, so upgrading does not require manually clearing the disk cache. - HTTP refusals such as 406 now rotate alongside the existing network, rate-limit, and runtime-error cases. A refusal from one mirror no longer prevents reaching healthy alternatives or persists under the seven-day road/month-long boundary cache TTLs. Concurrent identical queries share one mirror sequence; if it fails, both the initiating and joined callers can use the same last-good data. - A refusal every mirror agrees on is still reported with the first mirror's status and body, so a genuinely malformed query says what upstream said — but only after every mirror has had the chance to answer it. `fetchOverpassPayload` takes injectable endpoints and fetch so the rotation is tested without a live mirror (`src/overpassProxy.test.mjs`). +- Every mirror is asked with a User-Agent that names the application, its version and the project address (`OVERPASS_USER_AGENT` in `server/providers/overpass/constants.js`), which is what the OSM API usage policy asks for. Mirrors may refuse a client they cannot identify; such a refusal is never treated as data and costs the fan-out that mirror, so the header is what keeps the list at full strength. ### Share-link v2 layer state (August 2026) diff --git a/server/providers/overpass/constants.js b/server/providers/overpass/constants.js index aef0267eb..ddb53c01f 100644 --- a/server/providers/overpass/constants.js +++ b/server/providers/overpass/constants.js @@ -3,6 +3,19 @@ import path from 'node:path'; // --------------------------------------------------------------------------- // Overpass API proxy constants and cache state // --------------------------------------------------------------------------- +/** + * User-Agent sent to every Overpass mirror. + * + * The OSM API usage policy asks for a "Valid User-Agent identifying application + * and version"; a generic proxy label is not one. A mirror is free to refuse a + * client it cannot identify, and `src/overpassProxy.test.mjs` pins what that + * costs: a refusal is never data, so the query falls through to whatever + * mirrors are left. Keep this honest and stable — if it is ever refused, the + * answer is less query volume, not a new name. + */ +const OVERPASS_USER_AGENT = + 'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)'; + /** Ordered list of Overpass API mirrors; tried sequentially on failure/rate-limit. */ const OVERPASS_UPSTREAMS = [ 'https://overpass-api.de/api/interpreter', @@ -130,5 +143,6 @@ export { OVERPASS_SIMPLIFY_TOLERANCE_DEG, OVERPASS_MAX_RESPONSE_BYTES, OVERPASS_UPSTREAMS, + OVERPASS_USER_AGENT, OVERPASS_TIMEOUT_MS, }; diff --git a/server/providers/overpass/transport.js b/server/providers/overpass/transport.js index ff15ae0ff..28a608956 100644 --- a/server/providers/overpass/transport.js +++ b/server/providers/overpass/transport.js @@ -1,6 +1,7 @@ import { OVERPASS_MAX_RESPONSE_BYTES, OVERPASS_UPSTREAMS, + OVERPASS_USER_AGENT, OVERPASS_TIMEOUT_MS, } from './constants.js'; import { readResponseTextCapped } from '../common/http.js'; @@ -91,7 +92,7 @@ async function fetchOverpassPayload( method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': 'gods-eye-view-overpass-proxy/1.0', + 'User-Agent': OVERPASS_USER_AGENT, }, body, signal: controller.signal, @@ -125,12 +126,11 @@ async function fetchOverpassPayload( } // Anything but 2xx is this mirror declining, not an answer. Only 5xx used // to rotate, so a 4xx ended the fan-out and was returned — and cached — - // as data: overpass-api.de and its lz4 alias answer 406 to this proxy's - // User-Agent while kumi.systems and private.coffee answer 200 to the very - // same request, so every Overpass-backed layer failed on an Apache error - // page with two healthy mirrors untried. The first refusal is kept so a - // genuinely bad query still reports what upstream said, but only after - // every mirror has had the chance to answer it. + // as data: a mirror refusing this client answers 406 while the others + // answer 200 to the very same request, so every Overpass-backed layer + // failed on an error page with healthy mirrors untried. The first + // refusal is kept so a genuinely bad query still reports what upstream + // said, but only after every mirror has had the chance to answer it. if (status < 200 || status >= 300) { if (!lastRefusalPayload) lastRefusalPayload = payload; lastError = new Error( diff --git a/src/overpassProxy.test.mjs b/src/overpassProxy.test.mjs index 106946129..f25f20b16 100644 --- a/src/overpassProxy.test.mjs +++ b/src/overpassProxy.test.mjs @@ -97,6 +97,89 @@ test('a refusal moves to the next mirror instead of ending the fan-out', async ( assert.deepEqual(tried, ENDPOINTS.slice(0, 2), 'the healthy mirror must be reached, and no further'); }); +test('every mirror is asked with a User-Agent that identifies the application', async () => { + // The OSM API usage policy asks for a "Valid User-Agent identifying + // application and version". Every outbound request must carry it, not just + // the first: a mirror further down the list refusing an unidentified client + // is exactly the case the fan-out exists to survive. + const seen = []; + const fetchImpl = async (url, options) => { + seen.push({ url, agent: options?.headers?.['User-Agent'] }); + // Refuse everywhere, so the loop is forced through the whole list. + return { status: 503, headers: { get: () => 'text/html' } }; + }; + await fetchOverpassPayload('data=x', 1e6, { + endpoints: ENDPOINTS, + fetchImpl, + readBody: async () => 'upstream down', + simplify: (body) => body, + }); + + assert.deepEqual( + seen.map((request) => request.url), + ENDPOINTS, + 'the fan-out must reach every mirror', + ); + for (const request of seen) { + const agent = String(request.agent || ''); + assert.match( + agent, + /^gods-eye-view\/\d/, + `${request.url} must name the application and its version`, + ); + assert.ok( + !/proxy\/1\.0$/.test(agent), + `${request.url} must not fall back to the unidentified label`, + ); + assert.match( + agent, + /github\.com\/bilawalsidhu\/gods-eye-view/, + `${request.url} must carry a route back to the project`, + ); + } +}); + +test('a mirror that refuses the old label serves the same query under the identifying one', async () => { + // Recorded from the canonical instance on 2026-09-14: the unidentified label + // is answered with a 406 Not Acceptable HTML page before the query is read, + // and the identifying one is served. This stub replays that shape so the + // behaviour the header change buys is pinned without a live mirror. + const REFUSED = 'gods-eye-view-overpass-proxy/1.0'; + const answer = (agent) => + String(agent || '').startsWith(REFUSED) + ? { + status: 406, + body: '406 Not Acceptable', + contentType: 'text/html', + } + : { status: 200, body: DATA.body, contentType: 'application/json' }; + + let last = null; + const run = (agentOverride) => + fetchOverpassPayload('data=x', 1e6, { + endpoints: [ENDPOINTS[0]], + fetchImpl: async (url, options) => { + last = answer(agentOverride ?? options?.headers?.['User-Agent']); + return { status: last.status, headers: { get: () => last.contentType } }; + }, + readBody: async () => last.body, + simplify: (body) => body, + }); + + const refused = await run(REFUSED); + assert.equal(refused.status, 406, 'the old label is refused by the mirror'); + assert.equal( + overpassPayloadIsData(refused), + false, + 'a refusal is never treated as data', + ); + + const served = await run(undefined); + assert.equal(served.status, 200, 'the header the proxy now sends is served'); + assert.equal(overpassPayloadIsData(served), true); + assert.equal(served.body, DATA.body); +}); + test('the first mirror to answer wins, and the rest are left alone', async () => { const { payload, tried } = await run({ [ENDPOINTS[0]]: DATA, [ENDPOINTS[1]]: DATA, [ENDPOINTS[2]]: DATA, From d9fac6d88e494db331f50501aafe50baa35cac33 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:15:29 -0500 Subject: [PATCH 03/29] feat(ui): tilt and north-up compass controls (#571) Supersedes #442 by @yashveeeeeeer, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: Yashveer Singh --- CHANGELOG.md | 9 + docs/CURRENT-STATE.md | 21 +- scripts/format-scope.json | 2 + scripts/package-boundaries.json | 3 + src/cockpitMarkup.test.mjs | 19 +- src/ui/applicationShell.js | 18 + src/ui/cameraOrientationControls.js | 269 ++++++++++++ src/ui/cameraOrientationControls.test.mjs | 491 ++++++++++++++++++++++ src/ui/shellElements.js | 2 + src/ui/styles/cockpit.css | 9 +- src/ui/styles/recording.css | 8 +- src/ui/styles/responsive.css | 23 + src/ui/styles/status.css | 27 ++ src/ui/templates/scene-chrome.html | 7 + 14 files changed, 895 insertions(+), 13 deletions(-) create mode 100644 src/ui/cameraOrientationControls.js create mode 100644 src/ui/cameraOrientationControls.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 168d709cd..d78afc657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,15 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Added +- Two map-orientation controls sit beside Share in the top-center globe + actions. Tilt Map swings between a straight-down map and a 35-degree oblique + around the point under the centre of the view, keeping that point and the + distance to it. North Up rotates around the same point until north is at the + top, keeping the pitch, and its needle shows the current bearing. Both decline + without moving the camera when nothing is under the centre of the view, and + both follow Reset Globe out of Clean UI, recording, Scene playback and Cockpit + (#442 — thanks @yashveeeeeeer). + - Add Open Calgary traffic cameras as a keyless CCTV source pack (thanks @rileygramlich): the public City of Calgary catalog, frames pinned to the city's own host and upgraded to HTTPS, with the Open Government Licence – diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4305e0356..9fc27133a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1450,7 +1450,15 @@ This is the current runtime/source-of-truth snapshot for the project. > user intent. > Display orders 3D immediately above Celestial and Clean UI immediately > below it. The top-center action group places Clear Layers to the left of -> Share and Reset Globe to the right. Clear Layers turns off the currently +> Share, then Tilt Map and North Up, and Reset Globe to the right. Tilt Map +> swings the camera between a straight-down map and a 35-degree oblique around +> the point under the centre of the view, keeping that point and the distance +> to it; North Up rotates around the same point until north is at the top, +> keeping the pitch. Its needle shows the current bearing. Both decline +> without moving the camera when nothing is under the centre of the view (a +> camera facing the sky), and both are explicit camera actions, so either one +> releases a followed aircraft the way the other camera controls do. They +> follow Reset Globe out of Clean UI, recording, Scene playback and Cockpit. Clear Layers turns off the currently > selected manager-owned data layers, including an active Context choice, > while retaining visual, HUD, map, and panel settings. A disabled layer may > still release camera work that it owns through its normal teardown. @@ -1496,8 +1504,9 @@ This is the current runtime/source-of-truth snapshot for the project. > the Cockpit HUD at 145), scrolling internally when the layer list is longer than > the corridor. Cesium's credit line is never passable and still bounds the > corridor. Layer toggles stay live from there, and collapsing returns the -> plain launcher. The map-only Clear, Share, and Reset Globe actions are hidden -> for the duration of Cockpit, both as a group and as individual controls. +> plain launcher. The map-only Clear, Share, Tilt Map, North Up, and Reset +> Globe actions are hidden for the duration of Cockpit, both as a group and as +> individual controls. > It uses the `radar` symbol; both tabs are reachable with Tab and Left/Right arrows switch between them. Its action row > places the single Cockpit entry before Search Nearby Sites. Cockpit removes > the duplicate floating map entry and topline exit; the bottom-center @@ -1607,9 +1616,9 @@ This is the current runtime/source-of-truth snapshot for the project. > tray inside the screen. > Adaptive remeasurement preserves the user's > scroll position across Tactical, Minimal, and HUD Off layouts. -> During an active Scene run, Clear Selected Layers and Reset Globe remain -> hidden until playback stops or completes because the Scene transport owns -> layer and camera sequencing for that interval. +> During an active Scene run, Clear Selected Layers, Tilt Map, North Up and +> Reset Globe remain hidden until playback stops or completes because the +> Scene transport owns layer and camera sequencing for that interval. > The Cockpit Contact summary exposes Previous and Next contact navigation > plus its collapse control; it does not offer a Focus action because the > first-person Cockpit camera remains owned by the tracked aircraft. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8832b5ff7..054639107 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -150,6 +150,8 @@ "scripts/qa-visual-input.mjs", "src/ui/displayControls.js", "src/ui/displayControls.test.mjs", + "src/ui/cameraOrientationControls.js", + "src/ui/cameraOrientationControls.test.mjs", "src/ui/effects.js", "src/ui/visualEffects.js", "src/ui/visualPresets.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 23f7383d4..6ca0583d9 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -458,6 +458,7 @@ "src/data/installationFeedback.js", "src/data/layerState.js", "src/data/militaryAwarenessEngine.js", + "src/data/scenePick.js", "src/keySetupCore.mjs", "src/loadingFeedback.js", "src/locationStatus.js", @@ -473,6 +474,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/cameraOrientationControls.js", "src/ui/applicationShortcuts.js", "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -1490,6 +1492,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/cameraOrientationControls.js", "src/ui/applicationShortcuts.js", "src/ui/cctv.js", "src/ui/cctvBindings.js", diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 8acf28d16..371c0bc2c 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -45,7 +45,7 @@ test('Cockpit has one reset action beside its bottom exit path', () => { ); const topCenterActions = html.match(/