From d2c80cffdb6d8a25d6c271e6548df57d0c957c8e Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:57:29 -0400 Subject: [PATCH 01/20] fix(gbfs): exclude stations with invalid coordinates Reject non-numeric and out-of-range feed coordinates before they reach the map or bike-share queries. Preserve numeric strings, aliases and zero. Cover malformed records at the parser and public query boundaries. Signed-off-by: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> --- changelog.d/0-gbfs-invalid-coordinates.md | 3 + docs/CURRENT-STATE.md | 6 +- src/sources/gbfsStations.js | 20 ++++++- src/sources/gbfsStations.test.mjs | 67 +++++++++++++++++++++++ src/tools/queries/mobility.test.mjs | 40 ++++++++++++++ 5 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 changelog.d/0-gbfs-invalid-coordinates.md diff --git a/changelog.d/0-gbfs-invalid-coordinates.md b/changelog.d/0-gbfs-invalid-coordinates.md new file mode 100644 index 000000000..ed1f0824e --- /dev/null +++ b/changelog.d/0-gbfs-invalid-coordinates.md @@ -0,0 +1,3 @@ +- Skip bike-share stations with missing, blank or invalid coordinates instead + of placing them at coerced zero coordinates or accepting out-of-range values. + Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #0). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9562dc9f5..8aedf558a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1265,7 +1265,11 @@ camera is refused there with a JSON 404 and is served solely through ## GBFS upstream bounds Station information is normalized in `src/sources/gbfsStations.js` for both the -globe layer and `get_bike_share`. Names accept legacy strings or GBFS v3 Localized +globe layer and `get_bike_share`. Stations with missing, blank, nonnumeric or +out-of-range coordinates (latitude outside ±90, longitude outside ±180) are +skipped. Numbers and nonblank numeric strings are accepted, including genuine +zero and the `latitude`/`longitude` aliases; other JSON types are rejected. +Names accept legacy strings or GBFS v3 Localized String arrays regardless of the reported feed version. Nonempty English text (including regional `en-*` tags, case-insensitive) takes precedence, then the first usable translation. An unusable `name` falls back to `short_name` with the same diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js index c37c2fae4..c942579db 100644 --- a/src/sources/gbfsStations.js +++ b/src/sources/gbfsStations.js @@ -98,6 +98,13 @@ function dockCapacity(raw) { return capacity - geofenced; } +/** Read a numeric coordinate without treating blank text or other JSON types as zero. */ +function coordinateNumber(value) { + if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim())) + return NaN; + return Number(value); +} + /** * Parse a GBFS station_information payload into a Map of station metadata. * Handles field-name variations across different GBFS providers @@ -113,9 +120,16 @@ export function parseStationInformation(payload) { for (const raw of stations) { // Accept both GBFS (station_id) and alternate (id) field names const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); - const lat = Number(raw?.lat ?? raw?.latitude); - const lon = Number(raw?.lon ?? raw?.longitude); - if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; + const lat = coordinateNumber(raw?.lat ?? raw?.latitude); + const lon = coordinateNumber(raw?.lon ?? raw?.longitude); + if ( + !stationId || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + continue; stationMap.set(stationId, { stationId, diff --git a/src/sources/gbfsStations.test.mjs b/src/sources/gbfsStations.test.mjs index 91983df98..1ab273556 100644 --- a/src/sources/gbfsStations.test.mjs +++ b/src/sources/gbfsStations.test.mjs @@ -19,6 +19,73 @@ const parseCapacity = (fields) => parseStationInformation({ data: { stations: [station(fields)] } }).get('s1') .capacity; +test('station information skips missing, blank and malformed coordinates', () => { + for (const field of ['lat', 'lon', 'latitude', 'longitude']) { + for (const value of [ + undefined, + null, + '', + ' \t ', + false, + true, + [], + [0], + {}, + 'bad', + NaN, + Infinity, + ]) { + const raw = { station_id: 'bad', lat: 0, lon: 0 }; + if (field === 'latitude') delete raw.lat; + if (field === 'longitude') delete raw.lon; + raw[field] = value; + const parsed = parseStationInformation({ + data: { stations: [raw, station({ station_id: 'good' })] }, + }); + assert.deepEqual( + [...parsed.keys()], + ['good'], + `${field} = ${JSON.stringify(value)}`, + ); + } + } +}); + +test('station information skips coordinates outside latitude and longitude bounds', () => { + for (const fields of [ + { lat: 90.001 }, + { lat: -90.001 }, + { lon: 180.001 }, + { lon: -180.001 }, + { lat: undefined, latitude: '91' }, + { lon: undefined, longitude: '-181' }, + ]) { + const parsed = parseStationInformation({ + data: { stations: [station(fields)] }, + }); + assert.equal(parsed.size, 0, JSON.stringify(fields)); + } +}); + +test('station information preserves zero, boundary coordinates and numeric aliases', () => { + for (const [fields, expected] of [ + [{ lat: 0, lon: 0 }, [0, 0]], + [{ lat: ' 0 ', lon: '0' }, [0, 0]], + [{ lat: 90, lon: 180 }, [90, 180]], + [{ lat: '-90', lon: '-180' }, [-90, -180]], + [ + { lat: null, lon: undefined, latitude: '30.2681', longitude: '-97.7469' }, + [30.2681, -97.7469], + ], + ]) { + const parsed = parseStationInformation({ + data: { stations: [station(fields)] }, + }).get('s1'); + assert.ok(parsed, JSON.stringify(fields)); + assert.deepEqual([parsed.lat, parsed.lon], expected); + } +}); + test('capacity counts docks, not the geofenced area PBSC adds to it', () => { // Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17. assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39); diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 011e431fd..323173f03 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -171,6 +171,46 @@ test('bike share reports uncovered areas and failed systems honestly', async () ); }); +test('bike share excludes blank coordinates without losing stations on the prime meridian', async () => { + const stations = [ + { station_id: 'blank', lat: 51.5, lon: ' ' }, + { station_id: 'zero', lat: 51.5, lon: 0 }, + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { + bikeshare: { + systems: [system('fixture', 'Fixture Bikes', 51.5, 0)], + async getStations(url) { + return { + data: { + stations: url.endsWith('station_information.json') + ? stations + : stations.map(({ station_id }) => ({ + station_id, + num_bikes_available: 4, + })), + }, + }; + }, + }, + }, + }); + const result = await catalog.call('get_bike_share', { + area: { lat: 51.5, lon: 0, radius_km: 2 }, + }); + assert.deepEqual( + result.data.rows.map((row) => row.station_id), + ['zero'], + ); + assert.equal(result.data.total, 1); + assert.equal(result.data.rows[0].lon, 0); + assert.equal( + result.summary, + '1 bike-share station in 2 km around 51.500, 0.000 with 4 bikes available.', + ); +}); + const feed = { id: 'capmetro', name: 'CapMetro', From dd6e01353ab7897edc6d840177661ec481f5b394 Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:58:01 -0400 Subject: [PATCH 02/20] docs(gbfs): assign changelog fragment to PR 1232 Signed-off-by: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> --- ...-invalid-coordinates.md => 1232-gbfs-invalid-coordinates.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{0-gbfs-invalid-coordinates.md => 1232-gbfs-invalid-coordinates.md} (90%) diff --git a/changelog.d/0-gbfs-invalid-coordinates.md b/changelog.d/1232-gbfs-invalid-coordinates.md similarity index 90% rename from changelog.d/0-gbfs-invalid-coordinates.md rename to changelog.d/1232-gbfs-invalid-coordinates.md index ed1f0824e..4f3da2a97 100644 --- a/changelog.d/0-gbfs-invalid-coordinates.md +++ b/changelog.d/1232-gbfs-invalid-coordinates.md @@ -1,3 +1,3 @@ - Skip bike-share stations with missing, blank or invalid coordinates instead of placing them at coerced zero coordinates or accepting out-of-range values. - Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #0). + Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232). From 99429904d4c80709d4e4955aa908aedb9931972d Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 12:32:10 -0400 Subject: [PATCH 03/20] fix(draw): show a 999.6 m line as 1.0 km, not 1000 m formatMeasure checked the raw length against 1000 but printed it rounded, so a drawn line of 999.5 m or more read "1000 m" in the hint. Compare the rounded metres so it moves to kilometres. --- changelog.d/1388-draw-line-kilometre.md | 3 +++ src/annotations/drawMode.js | 7 ++++--- src/annotations/drawMode.test.mjs | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1388-draw-line-kilometre.md diff --git a/changelog.d/1388-draw-line-kilometre.md b/changelog.d/1388-draw-line-kilometre.md new file mode 100644 index 000000000..137075707 --- /dev/null +++ b/changelog.d/1388-draw-line-kilometre.md @@ -0,0 +1,3 @@ +- Show a drawn line of 999.5 m or more as 1.0 km. The draw hint kept metres + below 1000, then rounded 999.6 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1388). diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index fcfe08be6..538c2e5da 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -208,9 +208,10 @@ export function formatMeasure(session) { if (!session) return ''; if (session.shape === 'line') { const m = pathLengthM(session.vertices); - return m >= 1000 - ? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km` - : `${Math.round(m)} m`; + // Round before the kilometre cut. 999.6 m was printed as "1000 m". + const whole = Math.round(m); + if (whole < 1000) return `${whole} m`; + return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`; } if (session.shape === 'area') { const m2 = ringAreaM2(session.vertices); diff --git a/src/annotations/drawMode.test.mjs b/src/annotations/drawMode.test.mjs index 50475cc9e..be9894dda 100644 --- a/src/annotations/drawMode.test.mjs +++ b/src/annotations/drawMode.test.mjs @@ -330,3 +330,18 @@ test('longitude unwrapping is continuous and re-wraps to one canonical range', ( assert.equal(wrapLongitude(180), -180); assert.equal(unwrapLongitudes([]).length, 0); }); + +test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => { + const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180; + const lat = 999.6 / metresPerDegree; + const line = createDrawSession('line'); + addVertex(line, { lon: 0, lat: 0 }); + addVertex(line, { lon: 0, lat }); + const metres = pathLengthM(line.vertices); + assert.ok( + metres >= 999.5 && metres < 1000, + `length should round to 1000 m, got ${metres}`, + ); + assert.equal(formatMeasure(line), '1.0 km'); + assert.match(drawHint(line), /^1\.0 km · /); +}); From 5ef12391e143099e445912222f8105633dad0cb9 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 12:32:18 -0400 Subject: [PATCH 04/20] =?UTF-8?q?fix(cockpit):=20show=20wind=20from=20359.?= =?UTF-8?q?6=C2=B0=20as=200=C2=B0,=20not=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatCockpitWindDirection rounded the bearing and printed that number, so wind from 359.5° or more read "N · 360°". Take the rounded degrees modulo 360 so north reads 0°. --- changelog.d/1389-cockpit-wind-north.md | 3 +++ src/ui/cockpitPresentation.js | 4 +++- src/ui/cockpitPresentation.test.mjs | 10 ++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1389-cockpit-wind-north.md create mode 100644 src/ui/cockpitPresentation.test.mjs diff --git a/changelog.d/1389-cockpit-wind-north.md b/changelog.d/1389-cockpit-wind-north.md new file mode 100644 index 000000000..cc81c362e --- /dev/null +++ b/changelog.d/1389-cockpit-wind-north.md @@ -0,0 +1,3 @@ +- Show cockpit wind from 359.5° or more as 0°, not 360°. Rounding 359.6° + printed "N · 360°" on the local wind direction + (Rudy Celekli, @rudycelekli, #1389). diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 8285b74b3..b9c000503 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -156,7 +156,9 @@ export function formatCockpitWindDirection(value) { if (!Number.isFinite(value)) return t('DIR UNKNOWN'); const labels = Object.keys(COMPASS_POINT_LABELS); const normalized = ((value % 360) + 360) % 360; - return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${Math.round(normalized)}°`; + // 359.5° and above is north. Modulo keeps that from printing 360°. + const degrees = Math.round(normalized) % 360; + return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`; } // Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a diff --git a/src/ui/cockpitPresentation.test.mjs b/src/ui/cockpitPresentation.test.mjs new file mode 100644 index 000000000..da5c8e53b --- /dev/null +++ b/src/ui/cockpitPresentation.test.mjs @@ -0,0 +1,10 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { formatCockpitWindDirection } from './cockpitPresentation.js'; + +test('wind just west of north reads 0°, not 360°', () => { + assert.equal(formatCockpitWindDirection(90), 'E · 90°'); + assert.equal(formatCockpitWindDirection(0), 'N · 0°'); + assert.equal(formatCockpitWindDirection(359.4), 'N · 359°'); + assert.equal(formatCockpitWindDirection(359.6), 'N · 0°'); +}); From 63866c201af35acabcef580662cae152d62faf20 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 13:28:23 -0400 Subject: [PATCH 05/20] fix(awareness): wrap north-bound bearings --- changelog.d/1390-awareness-bearing-north.md | 2 ++ src/layers/awareness/presentation.js | 10 ++++++++++ src/layers/awareness/presentation.test.mjs | 11 +++++++++++ src/layers/awareness/rendering.js | 5 +++-- 4 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 changelog.d/1390-awareness-bearing-north.md create mode 100644 src/layers/awareness/presentation.js create mode 100644 src/layers/awareness/presentation.test.mjs diff --git a/changelog.d/1390-awareness-bearing-north.md b/changelog.d/1390-awareness-bearing-north.md new file mode 100644 index 000000000..af7fb4da3 --- /dev/null +++ b/changelog.d/1390-awareness-bearing-north.md @@ -0,0 +1,2 @@ +- Render awareness-overlay bearings and courses that round through north as + `000°`, rather than `360°` (Rudy Celekli, #1390). diff --git a/src/layers/awareness/presentation.js b/src/layers/awareness/presentation.js new file mode 100644 index 000000000..7439907d9 --- /dev/null +++ b/src/layers/awareness/presentation.js @@ -0,0 +1,10 @@ +/** + * Format a compass direction for the awareness overlay. + * @param {number} degrees + * @returns {string} + */ +export function formatAwarenessDirection(degrees) { + if (!Number.isFinite(degrees)) return '—'; + const normalized = ((degrees % 360) + 360) % 360; + return String(Math.round(normalized) % 360).padStart(3, '0'); +} diff --git a/src/layers/awareness/presentation.test.mjs b/src/layers/awareness/presentation.test.mjs new file mode 100644 index 000000000..54d40765f --- /dev/null +++ b/src/layers/awareness/presentation.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { formatAwarenessDirection } from './presentation.js'; + +test('awareness bearings that round through north read 000, not 360', () => { + assert.equal(formatAwarenessDirection(359.4), '359'); + assert.equal(formatAwarenessDirection(359.6), '000'); + assert.equal(formatAwarenessDirection(0), '000'); + assert.equal(formatAwarenessDirection(-0.4), '000'); + assert.equal(formatAwarenessDirection(Number.NaN), '—'); +}); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 8fe147e46..957e0bbdd 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -11,6 +11,7 @@ import { } from '../../data/militaryAwarenessEngine.js'; import { bearingBetweenCoordinates } from '../../cockpitMath.js'; import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js'; +import { formatAwarenessDirection } from './presentation.js'; export function createRendering({ state: layerState, @@ -225,12 +226,12 @@ export function createRendering({ : null; const bearingText = Number.isFinite(bearing) ? t('BRG {value}°', { - value: String(Math.round(bearing)).padStart(3, '0'), + value: formatAwarenessDirection(bearing), }) : t('BRG —'); const courseText = Number.isFinite(item.track) ? ` · ${t('CRS {value}°', { - value: String(Math.round(item.track)).padStart(3, '0'), + value: formatAwarenessDirection(item.track), })}` : ''; marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`; From 6ebdee6710bbfae5e678feb414b300a72a8a2efa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 12:11:19 -0700 Subject: [PATCH 06/20] docs: document PR #1388 rounding behavior --- docs/CURRENT-STATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9afb99c45..438d41776 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5240,6 +5240,7 @@ are omitted rather than framing the wrong part of the globe. - **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it. - **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen". - **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew. +- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. - **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it. - **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which. - Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection. From 8f025416573a88557ea65797750c31d80975960f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 12:11:49 -0700 Subject: [PATCH 07/20] docs: document PR #1389 rounding behavior --- docs/CURRENT-STATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9afb99c45..dedc6489b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5460,6 +5460,7 @@ pins and share links already supply complete boxes. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. +- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. From 69fba2ebbe44d04d237852cc6b0c71a00d9db1e6 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:36:50 -0700 Subject: [PATCH 08/20] fix(perf): free a turned-off layer's entities that Cesium keeps (#1367) Release stale Cesium command references after disabling a layer while preserving current-frame commands. Contribution by @quanticsoul4772. --- changelog.d/1367-turned-off-layer-memory.md | 4 + docs/CURRENT-STATE.md | 10 ++ scripts/package-boundaries.json | 3 +- src/app/tools.js | 10 ++ src/sceneCommandRelease.js | 74 +++++++++++ src/sceneCommandRelease.test.mjs | 137 ++++++++++++++++++++ 6 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1367-turned-off-layer-memory.md create mode 100644 src/sceneCommandRelease.js create mode 100644 src/sceneCommandRelease.test.mjs diff --git a/changelog.d/1367-turned-off-layer-memory.md b/changelog.d/1367-turned-off-layer-memory.md new file mode 100644 index 000000000..08bbbad81 --- /dev/null +++ b/changelog.d/1367-turned-off-layer-memory.md @@ -0,0 +1,4 @@ +- Turning a layer off frees the memory its entities held. Cesium kept + removed layers' draw commands in reused per-pass arrays, so Data Centers + kept 116 MiB after it was switched off, submarine cables 74 MiB and dams + 30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b9bc87ad2..b15babbb2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2159,6 +2159,16 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > always exits non-zero, `1` meaning the control is healthy and `2` meaning it > is not. Plus the unit pins above. +> **2026-10-24 — a turned-off layer's memory is released:** Cesium 1.138 +> keeps the draw commands past each frame's count in the opaque and other +> per-pass arrays (`View.frustumCommandsList`) and in the offscreen pick view, +> so a turned-off layer's entities stayed reachable (Data Centers kept 116 +> MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`, +> requests one frame through `governorRequestRender` after every layer +> disable and then trims those arrays. It reads private Cesium fields; +> `src/sceneCommandRelease.test.mjs` pins them and fails if Cesium renames +> them or changes which passes it truncates. + > **2026-08-08 — performance waves 1+2:** the app idles via an explicit render > governor (`src/renderGovernor.js` — hold/release from every per-frame > animator; discrete mutators call `governorRequestRender`). Any NEW diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c84a11cad..68a57066a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2574,7 +2574,8 @@ "src/voice/geminiSession.js", "src/voice/geminiAudio.js", "src/voice/geminiCaptureWorklet.js", - "src/ui/statusBadge.js" + "src/ui/statusBadge.js", + "src/sceneCommandRelease.js" ], "external": [ "hls.js", diff --git a/src/app/tools.js b/src/app/tools.js index 7b3ea83c9..7aea39c22 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -7,6 +7,7 @@ import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { installViews, isEmbeddedInline } from './embed.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; +import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { installRenderGovernor, getRenderGovernorDiagnostics, @@ -110,6 +111,15 @@ export function createApplicationTools({ // nothing animates per frame. Installed AFTER every module above has had // its chance to register pre-install holds. (perf wave 2) installRenderGovernor(viewer); + // Cesium keeps a turned-off layer's draw commands (and so all its entities) + // alive in reused per-pass arrays; trim them after each disable. + defer( + installLayerReleaseTrim({ + scene: viewer.scene, + dataManager, + requestRender: governorRequestRender, + }), + ); // Install the explicit scope mask used by the DISPLAY controls. installScopeMask(viewer); diff --git a/src/sceneCommandRelease.js b/src/sceneCommandRelease.js new file mode 100644 index 000000000..4d8594401 --- /dev/null +++ b/src/sceneCommandRelease.js @@ -0,0 +1,74 @@ +/** + * Let a turned-off layer's memory go. + * + * Cesium (engine 22.3, `Scene/View.js`) reuses each view's per-pass command + * arrays: every frame resets `frustumCommands.indices[pass]` to 0 and + * overwrites `commands[pass][i]` from the start. `Scene.js` truncates the + * voxel, Gaussian-splat and translucent passes to their counts; the opaque + * and other passes keep the slots past the new count. When a layer is turned + * off the next frame has fewer commands, so that tail keeps the destroyed + * primitives' DrawCommands, and through `_owner` → primitive → entity → + * EntityCollection the whole data source stays reachable. The offscreen view + * `scene.sampleHeight` / `pickFromRay` render into keeps its last list the + * same way, until the next height sample. + * + * Measured (production build, heap after GC, Data Centers on → off): 116 MiB + * kept on main, 13 MiB with this module; heap snapshots traced the entities + * through `View.frustumCommandsList` and `Picking._pickOffscreenView`. + * + * After a layer finishes turning off, this renders one frame, trims the main + * view's arrays to that frame's counts, and empties the offscreen pick view's + * lists (it rebuilds them in full on every pick). It reads private Cesium + * fields: `src/sceneCommandRelease.test.mjs` fails if any of them is renamed + * or if Cesium changes which passes it truncates, and once Cesium clears the + * tail itself this module can go. + */ + +/** Drop command references a view no longer draws. */ +function trimView(view, { keepCurrent }) { + for (const frustum of view?.frustumCommandsList || []) { + const { commands, indices } = frustum; + for (let pass = 0; pass < commands.length; pass += 1) { + const keep = keepCurrent ? indices[pass] : 0; + if (commands[pass].length > keep) commands[pass].length = keep; + if (!keepCurrent) indices[pass] = 0; + } + } + if (!keepCurrent) + for (const extent of view?._commandExtents || []) + extent.command = undefined; +} + +/** + * Release stale command references in the scene's main view (keeping what + * the last frame drew) and in its offscreen pick view (all of it). + * @param {object} scene Cesium Scene, right after a render. + */ +export function trimStaleSceneCommands(scene) { + trimView(scene._defaultView, { keepCurrent: true }); + trimView(scene._picking?._pickOffscreenView, { keepCurrent: false }); +} + +/** + * After every layer disable, render one frame and trim once it is drawn. + * @param {{scene: object, dataManager: object, requestRender: (reason: string) => void}} deps + * @returns {() => void} uninstall + */ +export function installLayerReleaseTrim({ scene, dataManager, requestRender }) { + let removeOneShot = null; + const unsubscribe = dataManager.subscribe((change) => { + if (change?.type !== 'visibility' || change.enabled !== false) return; + if (removeOneShot) return; // a trim is already waiting for its frame + removeOneShot = scene.postRender.addEventListener(() => { + removeOneShot(); + removeOneShot = null; + trimStaleSceneCommands(scene); + }); + requestRender('layer-release-trim'); + }); + return () => { + unsubscribe(); + removeOneShot?.(); + removeOneShot = null; + }; +} diff --git a/src/sceneCommandRelease.test.mjs b/src/sceneCommandRelease.test.mjs new file mode 100644 index 000000000..d88037872 --- /dev/null +++ b/src/sceneCommandRelease.test.mjs @@ -0,0 +1,137 @@ +// A turned-off layer's draw commands must not stay reachable through Cesium's +// reused per-pass command arrays (src/sceneCommandRelease.js). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { + installLayerReleaseTrim, + trimStaleSceneCommands, +} from './sceneCommandRelease.js'; + +const frustum = (lists, counts) => ({ + commands: lists.map((list) => [...list]), + indices: [...counts], +}); + +test('the main view keeps what the last frame drew and drops the stale tail', () => { + const view = { + frustumCommandsList: [frustum([['a', 'b', 'stale'], ['c'], []], [2, 1, 0])], + }; + trimStaleSceneCommands({ _defaultView: view }); + assert.deepEqual(view.frustumCommandsList[0].commands, [ + ['a', 'b'], + ['c'], + [], + ]); + assert.deepEqual(view.frustumCommandsList[0].indices, [2, 1, 0]); +}); + +test('the offscreen pick view is emptied, since every pick rebuilds it', () => { + const extents = [{ command: 'x' }, { command: 'y' }]; + const pick = { + frustumCommandsList: [frustum([['p', 'q'], ['r']], [2, 1])], + _commandExtents: extents, + }; + trimStaleSceneCommands({ + _defaultView: { frustumCommandsList: [] }, + _picking: { _pickOffscreenView: pick }, + }); + assert.deepEqual(pick.frustumCommandsList[0].commands, [[], []]); + assert.deepEqual(pick.frustumCommandsList[0].indices, [0, 0]); + assert.deepEqual( + extents.map((e) => e.command), + [undefined, undefined], + ); +}); + +test('a layer disable renders one frame and trims after it', () => { + const listeners = new Set(); + const postRender = new Set(); + const requests = []; + const view = { frustumCommandsList: [frustum([['a', 'stale']], [1])] }; + const scene = { + _defaultView: view, + postRender: { + addEventListener(fn) { + postRender.add(fn); + return () => postRender.delete(fn); + }, + }, + }; + const uninstall = installLayerReleaseTrim({ + scene, + dataManager: { + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }, + requestRender: (reason) => requests.push(reason), + }); + const emit = (change) => listeners.forEach((fn) => fn(change)); + + emit({ type: 'visibility', layerId: 'x', enabled: true }); + assert.equal(requests.length, 0, 'enabling trims nothing'); + emit({ type: 'visibility', layerId: 'x', enabled: false }); + emit({ type: 'visibility', layerId: 'y', enabled: false }); + assert.deepEqual( + requests, + ['layer-release-trim'], + 'one frame for back-to-back disables', + ); + assert.equal( + view.frustumCommandsList[0].commands[0].length, + 2, + 'not before the frame', + ); + postRender.forEach((fn) => fn()); + assert.deepEqual(view.frustumCommandsList[0].commands, [['a']]); + assert.equal(postRender.size, 0, 'the frame listener is one-shot'); + uninstall(); + assert.equal(listeners.size, 0); +}); + +// The workaround reads private Cesium fields. Pin the shape it relies on, and +// fail when Cesium starts clearing the tail itself so the module can go. +test('Cesium still reuses per-pass command arrays without clearing their tail', () => { + const require = createRequire(import.meta.url); + const enginePkg = require.resolve('@cesium/engine/package.json'); + const read = (rel) => + readFileSync(new URL(rel, pathToFileURL(enginePkg)), 'utf8'); + const scene = read('./Source/Scene/Scene.js'); + const view = read('./Source/Scene/View.js'); + const frustumCommands = read('./Source/Scene/FrustumCommands.js'); + const picking = read('./Source/Scene/Picking.js'); + // Every private field the trim reads. A rename would otherwise turn the + // trim into a silent no-op (it reaches them through `?.` and `|| []`). + assert.match(scene, /this\._picking = new Picking\(this\);/); + assert.match(scene, /this\._defaultView = new View\(/); + assert.match(picking, /this\._pickOffscreenView = new View\(/); + assert.match(view, /this\.frustumCommandsList = \[\];/); + assert.match(view, /frustumCommandsList\[n\]\.indices\[p\] = 0;/); + assert.match(view, /this\._commandExtents = \[\];/); + assert.match( + frustumCommands, + /this\.commands = commands;\s*this\.indices = indices;/, + ); + // Cesium truncates only the voxel, Gaussian-splat and translucent passes, + // each in its own draw step; the opaque and other passes keep their tail. + // A change here means Cesium's handling moved: re-check whether this + // module is still needed. + assert.doesNotMatch( + view, + /\.length\s*=\s*[^;]*indices\[/, + 'View.js now truncates the per-pass arrays: remove src/sceneCommandRelease.js', + ); + assert.deepEqual( + [ + ...scene.matchAll( + /commands\.length = frustumCommands\.indices\[Pass\.(\w+)\];/g, + ), + ].map((match) => match[1]), + ['VOXELS', 'GAUSSIAN_SPLATS', 'TRANSLUCENT'], + 'Scene.js truncates a different set of passes: re-check src/sceneCommandRelease.js', + ); +}); From ac8dc84f3cca503a55486de5211e69ab6f93b459 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Sun, 25 Oct 2026 19:43:01 +0000 Subject: [PATCH 09/20] docs: require screenshots, recording, category and data licence on new-layer PRs (#957) Document visual evidence, category and data licensing requirements for new layers, using the changelog fragment workflow. Contribution by @asturksever. --- .github/pull_request_template.md | 59 ++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 29 +++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..e8c9f8bdf --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,59 @@ +## What this changes + + + +## How I verified it + +- [ ] `npm run build` +- [ ] `npm test` +- [ ] `npm run test:track` (dev server running) +- [ ] Feature gate for the area I touched: + + + +## New layer checklist + + + +**Screenshots** + + + +**Recording** + + + +**Proposed category** + + + +Category: + +**Data source and licence** + +- Source (provider and endpoint): +- Licence or terms (link): +- Attribution shown in the app: +- Needs a key? (none / optional / required): +- Fetched at runtime, or bundled in the repo: + + + +- [ ] Added a row to `DATA_SOURCES.md` +- [ ] Credit is registered in `src/data/dataCredits.js` +- [ ] Share-link token assigned with `npm run layer-token:next`, if the layer is shareable + +## Docs + +- [ ] `docs/CURRENT-STATE.md` updated and a `changelog.d/-.md` fragment added, or this PR doesn't change runtime behaviour diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb2c2de9e..a7fe8ab76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,7 +108,7 @@ prints anything; never fix that by renaming or deleting PR A's published token. The highest-leverage places to jump in: - **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane. -- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template. +- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs. - **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened). - **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`. - **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md). @@ -153,6 +153,33 @@ ownership and adoption process. 3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/-.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly. 4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. 5. Describe what you changed and how you verified it (screenshots welcome for anything visual). +6. If you add a new layer, fill in the new-layer section of the PR template. See [Adding a new layer](#adding-a-new-layer). + +## Adding a new layer + +A new layer changes what everyone sees on the globe, so maintainers need to see +it working and know where its data comes from before reviewing the code. PRs +that add a layer must include all four of these in the description. The PR +template has a section for them. + +1. **Screenshots.** At least one of the layer on the globe, plus its info card + or panel if it has one. +2. **A screen recording**, preferably 15 to 30 seconds: turn the layer on, move + around, and open a record. Drag an `.mp4` or `.mov` into the PR description. + GitHub accepts videos up to 10 MB on free accounts, so link to a larger file + instead of attaching it. +3. **The proposed category.** Say which layer-panel group the layer belongs in. + The groups are defined in `PANEL_GROUPS` in `src/ui/layerPanel.js` (today: + Movement, Cameras, Infrastructure, Events, Weather and Utilities). A layer + that isn't listed there falls under "Other layers". If none of the groups + fits, propose a new one and explain why. +4. **The data source and licence.** Name the provider and endpoint, link its + licence or terms, give the attribution shown in the app, and say whether it + needs a key and whether the data is fetched at runtime or bundled. Add the + same details to [DATA_SOURCES.md](DATA_SOURCES.md) and register the credit + in `src/data/dataCredits.js`. + +Maintainers may hold a layer PR until all four are there. ## Maintainers From 57df5f61ba84ed23628180388241a1765a523a9b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 15:58:45 -0400 Subject: [PATCH 10/20] fix(fires): report a 00:45 UTC detection as 00:45, not "45" get_active_fires joined FIRMS acq_date and the unpadded acq_time, so a detection at 00:45 UTC came back as "2026-01-01 45". The row now uses acquisitionMsUtc, which pads the time, and reports an ISO UTC timestamp like the other tool times. --- changelog.d/1395-fires-tool-acquired-time.md | 4 ++++ src/tools/queries/hazards.js | 4 ++-- src/tools/queries/hazards.test.mjs | 17 +++++++++++++++++ src/tools/queries/queries.test.mjs | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1395-fires-tool-acquired-time.md create mode 100644 src/tools/queries/hazards.test.mjs diff --git a/changelog.d/1395-fires-tool-acquired-time.md b/changelog.d/1395-fires-tool-acquired-time.md new file mode 100644 index 000000000..ea3bddd1d --- /dev/null +++ b/changelog.d/1395-fires-tool-acquired-time.md @@ -0,0 +1,4 @@ +- Report a fire detection's acquisition time as a UTC timestamp in + `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a + 00:45 UTC detection read "2026-01-01 45"; it now reads + `2026-01-01T00:45:00.000Z` (Rudy Celekli, @rudycelekli, #1395). diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 475df97ba..f644be562 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -1,5 +1,6 @@ /** Natural hazard queries: earthquakes and active fires. */ +import { acquisitionMsUtc } from '../../data/firmsCsv.js'; import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; @@ -114,8 +115,7 @@ export const getActiveFires = defineTool({ lon: fire.lon, frp_mw: fire.frp ?? null, confidence: fire.confidence ?? null, - acquired: - [fire.acqDate, fire.acqTime].filter(Boolean).join(' ') || null, + acquired: isoTime(acquisitionMsUtc(fire.acqDate, fire.acqTime)), satellite: fire.satellite ?? null, day_night: fire.daynight ?? null, })), diff --git a/src/tools/queries/hazards.test.mjs b/src/tools/queries/hazards.test.mjs new file mode 100644 index 000000000..fe2e6ac1f --- /dev/null +++ b/src/tools/queries/hazards.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog } from '../catalog.js'; +import { getActiveFires } from './hazards.js'; + +const catalogWith = (fires) => + composeCatalog({ + tools: [getActiveFires], + services: { fires: { getSnapshot: async () => ({ fires }) } }, + }); + +test('a fire acquired at 00:45 UTC reads as 00:45, not "45"', async () => { + const result = await catalogWith([ + { lat: 24, lon: 121, frp: 5, acqDate: '2026-01-01', acqTime: '45' }, + ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); + assert.equal(result.data.rows[0].acquired, '2026-01-01T00:45:00.000Z'); +}); diff --git a/src/tools/queries/queries.test.mjs b/src/tools/queries/queries.test.mjs index b64f5e009..5f53cf9e8 100644 --- a/src/tools/queries/queries.test.mjs +++ b/src/tools/queries/queries.test.mjs @@ -125,7 +125,7 @@ test('fires need an area and report a missing key as unavailable', async () => { result.data.rows.map((row) => row.frp_mw), [40, 12], ); - assert.equal(result.data.rows[1].acquired, '2026-01-01 0130'); + assert.equal(result.data.rows[1].acquired, '2026-01-01T01:30:00.000Z'); const keyless = composeCatalog({ tools: coreTools, From 315c0f5dc61304cdf0adbd71e5efb1633e47e474 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 15:58:46 -0400 Subject: [PATCH 11/20] =?UTF-8?q?fix(local-adsb):=20show=20a=20359.7=C2=B0?= =?UTF-8?q?=20track=20as=20TRK=200=C2=B0,=20not=20TRK=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Local ADS-B card rounded the track without wrapping, so an aircraft tracking 359.5° or more read "TRK 360°". The rounded track now wraps to 0°. --- changelog.d/1396-local-adsb-track-north.md | 3 +++ src/layers/localAdsb/card.js | 2 +- src/layers/localAdsb/card.test.mjs | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1396-local-adsb-track-north.md create mode 100644 src/layers/localAdsb/card.test.mjs diff --git a/changelog.d/1396-local-adsb-track-north.md b/changelog.d/1396-local-adsb-track-north.md new file mode 100644 index 000000000..9235a19a3 --- /dev/null +++ b/changelog.d/1396-local-adsb-track-north.md @@ -0,0 +1,3 @@ +- Show a locally heard aircraft tracking 359.5° or more as TRK 0°. The Local + ADS-B card rounded the track without wrapping, so 359.7° read "TRK 360°" + (Rudy Celekli, @rudycelekli, #1396). diff --git a/src/layers/localAdsb/card.js b/src/layers/localAdsb/card.js index 1b0122925..c1c923a8c 100644 --- a/src/layers/localAdsb/card.js +++ b/src/layers/localAdsb/card.js @@ -152,7 +152,7 @@ export function localAdsbCardModel( t('ALT {altitude} FT · GS {speed} KT · TRK {track}', { altitude: grouped(altitude), speed: speed === null ? DASH : speed, - track: track === null ? DASH : `${track}°`, + track: track === null ? DASH : `${track % 360}°`, }), vertical === null ? t('V/S {rate}', { rate: DASH }) diff --git a/src/layers/localAdsb/card.test.mjs b/src/layers/localAdsb/card.test.mjs new file mode 100644 index 000000000..ec53024da --- /dev/null +++ b/src/layers/localAdsb/card.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../../i18n/index.js'; +import { localAdsbCardModel } from './card.js'; + +test('a track of 359.7° reads TRK 0°, not TRK 360°', () => { + setLocale('en'); + const { details } = localAdsbCardModel( + { + icao: 'abc123', + altitudeFt: 12500, + groundSpeedKt: 240, + trackDeg: 359.7, + lastPositionAt: 1000, + }, + 5000, + ); + assert.equal(details[1], 'ALT 12,500 FT · GS 240 KT · TRK 0°'); +}); From 224d71753b02eb2fde881e283e1053e9ca2b639b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 16:01:26 -0400 Subject: [PATCH 12/20] fix(fires): parse the detection time inside the tools package The tools package cannot import firmsCsv. Pad the FIRMS clock here so 00:45 UTC still reads as 00:45. --- src/tools/queries/hazards.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index f644be562..194f9b8c1 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -1,11 +1,24 @@ /** Natural hazard queries: earthquakes and active fires. */ -import { acquisitionMsUtc } from '../../data/firmsCsv.js'; import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +function acquiredMsUtc(acqDate, acqTime) { + if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate)) + return NaN; + const timeText = String(acqTime ?? '').trim(); + if (!/^\d{1,4}$/.test(timeText)) return NaN; + const hhmm = timeText.padStart(4, '0'); + const hours = Number(hhmm.slice(0, 2)); + const minutes = Number(hhmm.slice(2, 4)); + if (hours > 23 || minutes > 59) return NaN; + return Date.parse( + `${acqDate}T${hhmm.slice(0, 2)}:${hhmm.slice(2, 4)}:00.000Z`, + ); +} + export const getEarthquakes = defineTool({ name: 'get_earthquakes', title: 'Recent earthquakes', @@ -115,7 +128,7 @@ export const getActiveFires = defineTool({ lon: fire.lon, frp_mw: fire.frp ?? null, confidence: fire.confidence ?? null, - acquired: isoTime(acquisitionMsUtc(fire.acqDate, fire.acqTime)), + acquired: isoTime(acquiredMsUtc(fire.acqDate, fire.acqTime)), satellite: fire.satellite ?? null, day_night: fire.daynight ?? null, })), From 3de745bd33123b9203e2b160d8bbbb6772d399a1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 14:08:25 -0700 Subject: [PATCH 13/20] fix(fires): reject rolled-over acquisition dates and document UTC times --- changelog.d/1395-fires-tool-acquired-time.md | 4 ++- docs/CURRENT-STATE.md | 3 ++ src/tools/queries/hazards.js | 6 +++- src/tools/queries/hazards.test.mjs | 29 ++++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/changelog.d/1395-fires-tool-acquired-time.md b/changelog.d/1395-fires-tool-acquired-time.md index ea3bddd1d..17d74e481 100644 --- a/changelog.d/1395-fires-tool-acquired-time.md +++ b/changelog.d/1395-fires-tool-acquired-time.md @@ -1,4 +1,6 @@ - Report a fire detection's acquisition time as a UTC timestamp in `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a 00:45 UTC detection read "2026-01-01 45"; it now reads - `2026-01-01T00:45:00.000Z` (Rudy Celekli, @rudycelekli, #1395). + `2026-01-01T00:45:00.000Z`. Missing or invalid dates and times remain + unknown rather than rolling into another calendar day + (Rudy Celekli, @rudycelekli, #1395). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b15babbb2..5332216e5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1984,6 +1984,9 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. +The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, +including zero-padded hours and minutes; missing or invalid dates and times +remain `null`, including impossible calendar dates from a custom source. The FIRMS proxy records source success after appending its rows. If aggregation throws, that source reports failure without a contradictory success entry. diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 194f9b8c1..2b11c639c 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -14,9 +14,13 @@ function acquiredMsUtc(acqDate, acqTime) { const hours = Number(hhmm.slice(0, 2)); const minutes = Number(hhmm.slice(2, 4)); if (hours > 23 || minutes > 59) return NaN; - return Date.parse( + const timestamp = Date.parse( `${acqDate}T${hhmm.slice(0, 2)}:${hhmm.slice(2, 4)}:00.000Z`, ); + return Number.isFinite(timestamp) && + new Date(timestamp).toISOString().slice(0, 10) === acqDate + ? timestamp + : NaN; } export const getEarthquakes = defineTool({ diff --git a/src/tools/queries/hazards.test.mjs b/src/tools/queries/hazards.test.mjs index fe2e6ac1f..fa17158dd 100644 --- a/src/tools/queries/hazards.test.mjs +++ b/src/tools/queries/hazards.test.mjs @@ -15,3 +15,32 @@ test('a fire acquired at 00:45 UTC reads as 00:45, not "45"', async () => { ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); assert.equal(result.data.rows[0].acquired, '2026-01-01T00:45:00.000Z'); }); + +test('fire acquisition timestamps reject impossible calendar dates', async () => { + for (const acqDate of [ + '2026-02-29', + '2026-02-30', + '2026-04-31', + '1900-02-29', + '2026-13-01', + '2026-01-00', + ]) { + const result = await catalogWith([ + { lat: 24, lon: 121, acqDate, acqTime: '45' }, + ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); + assert.equal(result.data.rows[0].acquired, null, acqDate); + } +}); + +test('fire acquisition timestamps preserve valid leap days and UTC times', async () => { + for (const [acqDate, acqTime, expected] of [ + ['2024-02-29', '45', '2024-02-29T00:45:00.000Z'], + ['2000-02-29', 0, '2000-02-29T00:00:00.000Z'], + ['2026-04-30', '2359', '2026-04-30T23:59:00.000Z'], + ]) { + const result = await catalogWith([ + { lat: 24, lon: 121, acqDate, acqTime }, + ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); + assert.equal(result.data.rows[0].acquired, expected); + } +}); From 6642176de540776a04a7b3d9ed6413c190504530 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 14:08:32 -0700 Subject: [PATCH 14/20] docs: document Local ADS-B track rounding --- docs/CURRENT-STATE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b15babbb2..921604789 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5994,6 +5994,8 @@ callsign, class and category, adsbdb operator/type and a plausible route when known, altitude, ground speed, track, vertical rate, position age, message count, "Heard by your receiver"); markers are not camera-followed. Positioned aircraft join detection boxes, labelled only with a decoded callsign. +The card's `TRK` label wraps after rounding to whole degrees, so a track of +359.5° displays as `TRK 0°` rather than `TRK 360°`. Receivers publish one record shape from `src/sources/adsbRecords.js` (`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`, From 1ad472fecf9d35f6f64b4ba537e0f09692373a2e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 14:08:32 -0700 Subject: [PATCH 15/20] fix(awareness): register and document bearing presentation --- docs/CURRENT-STATE.md | 1 + scripts/package-boundaries.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b15babbb2..722fcd831 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4681,6 +4681,7 @@ silently demoting every later lookup for the session. - Selected AIS vessels use their layer-owned full-detail presentation model in the shared world-overlay host; mapped installations promote their own entry to a selected card. Both remain crisp above post-processing without duplicate selected labels; non-selected AIS cards retain their source-owned grid/visibility selection and are host-batched with other world cards. - Space Mission ascent replay uses the compact rocket/thrust overlay only through orbit insertion. Once the replay enters its orbit phase, that vehicle glyph is replaced by a fixed-size cyan dot following the same orbit path and callout. - While a subject is selected, an inner keyhole compass rotates against camera heading and up to three cyan shafted bearing arrows lock to its single faint tick-marked rim, with their labels inset just inside the circle. Labels explicitly separate the geographic bearing (`BRG`) from the contact's reported course (`CRS`) so the pointer direction is not confused with aircraft heading. They point toward the nearest observed/mapped examples; each cohort displays up to ten examples while retaining the complete locally loaded in-range cohort for navigation, with three visible at a time and a ten-second page rotation shared by the panel and arrows. This distinct neutral-context color avoids implying that all context indicators are military-flight symbols. `PREVIOUS`, `FOCUS`, and `NEXT` controls navigate selection history or the next nearby cohort example through the existing tracker. NEXT uses a cycle-scoped visited set and starts a deterministic new walk after exhausting the current candidates instead of re-admitting the nearest visited contact. +- Awareness `BRG` and `CRS` labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `000°` rather than `360°`. - Installations are viewport-bounded OSM map features (`military=airfield|naval_base|range|barracks|base` and `landuse=military`), capped to a 10° non-dateline request and 700 upstream features. The proxy caches five minutes and serves a one-hour stale fallback. Empty, stale, unavailable, and zoom-too-wide states remain visibly distinct. - With a selected subject, Contacts hands the installation layer the subject position every refresh. The layer loads a window around the subject (see Installations and map-source guidance), because a follow or Cockpit camera never fires `moveEnd` and often looks at the horizon: a first load from a globe view used to leave the row at "?" for the whole track. The row and the Cockpit scope read `WITHIN 100 KM` instead of `CURRENT VIEWPORT ONLY`, and an empty window reads "none mapped within 100 km; not a complete 250 km survey". `scripts/qa-context-arrivals.mjs` flies synthetic aircraft over Camp Mabry and Fort Cavazos from a wide start, presses CONTACTS, Cockpit and SEARCH NEARBY SITES, and records a clip. - The selected-only visual is one static, unfilled blue circle. It marks the 250 km proximity context window only; it is not coverage or a radar/weapon envelope. Missing broadcasts and unmapped sites are explicitly not evidence of absence. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 68a57066a..cddb29c40 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1287,6 +1287,7 @@ "src/layers/awareness/navigation.js", "src/layers/awareness/panel.js", "src/layers/awareness/policy.js", + "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", @@ -1944,6 +1945,7 @@ "src/layers/awareness/navigation.js", "src/layers/awareness/panel.js", "src/layers/awareness/policy.js", + "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", @@ -2736,6 +2738,7 @@ "src/layers/awareness/navigation.js", "src/layers/awareness/panel.js", "src/layers/awareness/policy.js", + "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", From 7bc86224e942df1304d0532b57256e87ad5d55f3 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:28:09 -0400 Subject: [PATCH 16/20] fix(route): phrase ramp and lane modifiers as directions (#1343) Correct ramp and lane instructions consistently in server output and localized browser text. Contribution by @rudycelekli. --- changelog.d/1343-route-ramp-lane-wording.md | 4 ++ docs/CURRENT-STATE.md | 4 ++ src/data/directions.test.mjs | 43 +++++++++++++++++++++ src/data/routeSteps.js | 33 ++++++++++++---- src/data/routeSteps.test.mjs | 20 ++++++++++ src/i18n/locales/es.json | 4 ++ src/i18n/locales/ru.json | 4 ++ src/i18n/locales/zh-CN.json | 4 ++ src/layers/directions/index.js | 24 +++++++++--- 9 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 changelog.d/1343-route-ramp-lane-wording.md diff --git a/changelog.d/1343-route-ramp-lane-wording.md b/changelog.d/1343-route-ramp-lane-wording.md new file mode 100644 index 000000000..4f6817e9c --- /dev/null +++ b/changelog.d/1343-route-ramp-lane-wording.md @@ -0,0 +1,4 @@ +- Phrase slight and sharp ramps as directions, so a ramp reads "slightly left" + instead of "on the slightly left", and a lane names its side in both + the proxy response and localized browser instructions + (Rudy Celekli, @rudycelekli, #1343). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b0865b7b0..cc4e7b861 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4150,6 +4150,10 @@ and as the last line of the list, because the arrival step is among the ones dropped. A route of exactly 200 is complete, and a roundabout exit after the 200th keeps folding into it. +Directions ramp and exit text uses “on the left/right” only for a bare side; +slight and sharp modifiers read as “slightly/sharply left/right”. Lane instructions +name the left or right lane. The localized page and proxy English stay in sync. + Route duration labels select their unit after rounding seconds, switching to minutes at 59.5 seconds; a 59.6-second leg displays as `1 min`, rather than `60 s`. diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index b0c7503ac..bf6e2878e 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -876,3 +876,46 @@ test('route numbers follow the page language and never break from their unit', ( const list = directionsStepList({ route }); assert.equal(list.items[1].lead, '240\u00a0м'); }); + +test('ramp and lane wording stays localized after choosing the maneuver form', (t) => { + t.after(() => setLocale('en')); + const cases = [ + [ + 'ru', + 'Въезжайте по съезду плавно налево на I-95', + 'Сверните на съезд резко направо', + 'Займите левую полосу', + ], + [ + 'es', + 'Toma la rampa ligeramente a la izquierda hacia I-95', + 'Toma la salida bruscamente a la derecha', + 'Toma el carril izquierdo', + ], + [ + 'zh-CN', + '向左前方驶入匝道,进入 I-95', + '从出口向右后方驶出', + '使用左转车道', + ], + ]; + for (const [locale, ramp, exit, lane] of cases) { + setLocale(locale); + assert.equal( + directionsInstruction({ + type: 'on ramp', + modifier: 'slight left', + name: 'I-95', + }), + ramp, + ); + assert.equal( + directionsInstruction({ type: 'off ramp', modifier: 'sharp right' }), + exit, + ); + assert.equal( + directionsInstruction({ type: 'use lane', modifier: 'sharp left' }), + lane, + ); + } +}); diff --git a/src/data/routeSteps.js b/src/data/routeSteps.js index e3144bdf7..736d812ee 100644 --- a/src/data/routeSteps.js +++ b/src/data/routeSteps.js @@ -54,6 +54,21 @@ function direction(modifier) { return DIRECTION_WORDS[key] || key; } +/** + * "on the left" only fits a bare side. "slight left" is the adverb + * "slightly left", and "on the slightly left" is not an instruction. + * @param {string} prefix "Take the ramp" or "Take the exit". + * @param {string} modifier OSRM maneuver modifier. + * @param {string} onto " onto …", or "". + */ +function directedWay(prefix, modifier, onto) { + const dir = direction(modifier); + if (dir === 'left' || dir === 'right') + return `${prefix} on the ${dir}${onto}`; + if (dir && dir !== 'straight') return `${prefix} ${dir}${onto}`; + return `${prefix}${onto}`; +} + function ordinal(n) { if (!Number.isInteger(n) || n < 1) return null; const suffix = @@ -103,13 +118,9 @@ export function instructionFor(step) { case 'merge': return dir ? `Merge ${dir}${onto}` : `Merge${onto}`; case 'on ramp': - return dir - ? `Take the ramp on the ${dir}${onto}` - : `Take the ramp${onto}`; + return directedWay('Take the ramp', modifier, onto); case 'off ramp': - return dir - ? `Take the exit on the ${dir}${onto}` - : `Take the exit${onto}`; + return directedWay('Take the exit', modifier, onto); case 'roundabout': case 'rotary': { const exit = ordinal(step?.exit); @@ -124,8 +135,14 @@ export function instructionFor(step) { case 'exit roundabout': case 'exit rotary': return `Exit the roundabout${onto}`; - case 'use lane': - return dir ? `Use the ${dir} lane${onto}` : `Continue${onto}`; + case 'use lane': { + const lane = modifier.includes('left') + ? 'left' + : modifier.includes('right') + ? 'right' + : ''; + return lane ? `Use the ${lane} lane${onto}` : `Continue${onto}`; + } case 'notification': default: return `Continue${onto}`; diff --git a/src/data/routeSteps.test.mjs b/src/data/routeSteps.test.mjs index 6bf74dd08..c0eb445dc 100644 --- a/src/data/routeSteps.test.mjs +++ b/src/data/routeSteps.test.mjs @@ -77,6 +77,26 @@ test('instructions read like a navigator, one sentence per OSRM maneuver', () => instructionFor({ type: 'on ramp', modifier: 'left' }), 'Take the ramp on the left', ); + assert.equal( + instructionFor({ type: 'on ramp', modifier: 'slight left', name: 'I-95' }), + 'Take the ramp slightly left onto I-95', + ); + assert.equal( + instructionFor({ type: 'off ramp', modifier: 'sharp right', ref: 'I-280' }), + 'Take the exit sharply right onto I-280', + ); + assert.equal( + instructionFor({ type: 'on ramp', modifier: 'straight', name: 'US 101' }), + 'Take the ramp onto US 101', + ); + assert.equal( + instructionFor({ type: 'use lane', modifier: 'slight left' }), + 'Use the left lane', + ); + assert.equal( + instructionFor({ type: 'use lane', modifier: 'right', name: 'Main St' }), + 'Use the right lane onto Main St', + ); assert.equal( instructionFor({ type: 'merge', diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 4fe130f23..098c82f55 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1867,11 +1867,15 @@ "maneuver|Merge": "Incorpórate", "Merge onto {road}": "Incorpórate a {road}", "Take the ramp on the {direction}": "Toma la rampa {direction}", + "Take the ramp {direction}": "Toma la rampa {direction}", "Take the ramp on the {direction} onto {road}": "Toma la rampa {direction} hacia {road}", + "Take the ramp {direction} onto {road}": "Toma la rampa {direction} hacia {road}", "Take the ramp": "Toma la rampa", "Take the ramp onto {road}": "Toma la rampa hacia {road}", "Take the exit on the {direction}": "Toma la salida {direction}", + "Take the exit {direction}": "Toma la salida {direction}", "Take the exit on the {direction} onto {road}": "Toma la salida {direction} hacia {road}", + "Take the exit {direction} onto {road}": "Toma la salida {direction} hacia {road}", "Take the exit": "Toma la salida", "Take the exit onto {road}": "Toma la salida hacia {road}", "At the roundabout, take the {exit} exit": "En la rotonda, toma la {exit} salida", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 978640951..8d12ba43e 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1899,11 +1899,15 @@ "maneuver|Merge": "Перестройтесь", "Merge onto {road}": "Въезжайте на {road}", "Take the ramp on the {direction}": "Въезжайте на съезд {direction}", + "Take the ramp {direction}": "Въезжайте на съезд {direction}", "Take the ramp on the {direction} onto {road}": "Въезжайте по съезду {direction} на {road}", + "Take the ramp {direction} onto {road}": "Въезжайте по съезду {direction} на {road}", "Take the ramp": "Въезжайте на съезд", "Take the ramp onto {road}": "Въезжайте по съезду на {road}", "Take the exit on the {direction}": "Сверните на съезд {direction}", + "Take the exit {direction}": "Сверните на съезд {direction}", "Take the exit on the {direction} onto {road}": "Сверните на съезд {direction} на {road}", + "Take the exit {direction} onto {road}": "Сверните на съезд {direction} на {road}", "Take the exit": "Сверните на съезд", "Take the exit onto {road}": "Сверните на съезд на {road}", "At the roundabout, take the {exit} exit": "На кольце сверните на {exit} съезд", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 01c495a27..e727b8516 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -626,11 +626,15 @@ "Merge onto {road}": "并入 {road}", "maneuver|Merge": "并道", "Take the ramp on the {direction} onto {road}": "{direction}驶入匝道,进入 {road}", + "Take the ramp {direction} onto {road}": "{direction}驶入匝道,进入 {road}", "Take the ramp on the {direction}": "{direction}驶入匝道", + "Take the ramp {direction}": "{direction}驶入匝道", "Take the ramp onto {road}": "驶入匝道,进入 {road}", "Take the ramp": "驶入匝道", "Take the exit on the {direction} onto {road}": "从出口{direction}驶出,进入 {road}", + "Take the exit {direction} onto {road}": "从出口{direction}驶出,进入 {road}", "Take the exit on the {direction}": "从出口{direction}驶出", + "Take the exit {direction}": "从出口{direction}驶出", "Take the exit onto {road}": "从出口驶出,进入 {road}", "Take the exit": "从出口驶出", "At the roundabout, take the {exit} exit onto {road}": "在环岛从{exit}出口驶出,进入 {road}", diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index e90e1be18..b8cceff02 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -308,16 +308,24 @@ export function directionsInstruction(step) { ? t('Merge onto {road}', values) : t('Merge', { context: 'maneuver' }); case 'on ramp': - if (direction) + if (modifier === 'left' || modifier === 'right') return road ? t('Take the ramp on the {direction} onto {road}', values) : t('Take the ramp on the {direction}', values); + if (direction && modifier !== 'straight') + return road + ? t('Take the ramp {direction} onto {road}', values) + : t('Take the ramp {direction}', values); return road ? t('Take the ramp onto {road}', values) : t('Take the ramp'); case 'off ramp': - if (direction) + if (modifier === 'left' || modifier === 'right') return road ? t('Take the exit on the {direction} onto {road}', values) : t('Take the exit on the {direction}', values); + if (direction && modifier !== 'straight') + return road + ? t('Take the exit {direction} onto {road}', values) + : t('Take the exit {direction}', values); return road ? t('Take the exit onto {road}', values) : t('Take the exit'); case 'roundabout': case 'rotary': @@ -346,9 +354,14 @@ export function directionsInstruction(step) { return road ? t('Exit the roundabout onto {road}', values) : t('Exit the roundabout'); - case 'use lane': - if (direction) { - const lane = { road, direction: laneWord(modifier) }; + case 'use lane': { + const side = modifier.includes('left') + ? 'left' + : modifier.includes('right') + ? 'right' + : ''; + if (side) { + const lane = { road, direction: laneWord(side) }; return road ? t('Use the {direction} lane onto {road}', lane) : t('Use the {direction} lane', lane); @@ -356,6 +369,7 @@ export function directionsInstruction(step) { return road ? t('Continue onto {road}', values) : t('Continue', { context: 'maneuver' }); + } default: return road ? t('Continue onto {road}', values) From a7da90fdfcbc17673cccb9f761930ccb812fcfe9 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:51:56 -0700 Subject: [PATCH 17/20] fix(effects): run no sharpen pass when its slider is at 0 (#1398) Disable the sharpen pass when its intensity is zero. Contribution by @quanticsoul4772. --- changelog.d/1398-sharpen-zero-off.md | 2 ++ docs/CURRENT-STATE.md | 2 ++ src/ui/visualEffects.js | 14 +++++++++++++- src/ui/visualEffects.test.mjs | 18 ++++++++++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1398-sharpen-zero-off.md diff --git a/changelog.d/1398-sharpen-zero-off.md b/changelog.d/1398-sharpen-zero-off.md new file mode 100644 index 000000000..ab05aa6f2 --- /dev/null +++ b/changelog.d/1398-sharpen-zero-off.md @@ -0,0 +1,2 @@ +- The Sharpen slider at 0 now turns the sharpen pass off instead of still + sharpening at a low amount (quanticsoul4772, #1398). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 07daef46c..e2e24fc7d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1301,6 +1301,8 @@ Stopping the controller revokes animation before asynchronous UI teardown; final destruction removes its stages and restores the borrowed bloom configuration. Snow's sideways sway stays within a fixed range at any session age; Wind scales its amplitude. +Sharpen runs only when enabled with intensity above 0.001. At slider zero its +stage is disabled; raising the slider resumes it when sharpen is enabled. ## Display control ownership diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index 8b5234ef2..9d10f0cef 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -159,18 +159,30 @@ export class VisualEffects { this.requestRender('bloom'); } + /** + * Sharpen runs only when it is on and its slider is above 0, as the style + * stages do: the pass costs the same at any amount (~1.5 ms GPU per frame + * on the Arc, 2026-10-24), and `0.1 + value * 2` still sharpened at 0. + */ + syncSharpenEnabled() { + if (this.stopped || !this.sharpenStage) return; + this.sharpenStage.enabled = + this.sharpenEnabled && this.sharpenIntensity > 0.001; + } + applySharpenIntensity(value) { if (this.stopped) return; this.sharpenIntensity = value; if (this.sharpenStage) this.sharpenStage.uniforms.amount = 0.1 + value * 2.0; + this.syncSharpenEnabled(); this.requestRender('sharpen'); } setSharpenEnabled(enabled) { if (this.stopped) return; this.sharpenEnabled = !!enabled; - if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled; + this.syncSharpenEnabled(); this.requestRender('sharpen'); } diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index 68fa45962..9e9542c52 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -224,6 +224,24 @@ test('sharpen uses the existing uniform scale and keeps values while disabled', f.effects.destroy(); }); +test('sharpen at 0 runs no pass, and comes back with the slider', () => { + const f = fixture(); + f.effects.initPostProcess(0.49); + f.effects.setSharpenEnabled(true); + assert.equal(f.effects.sharpenStage.enabled, true); + f.effects.applySharpenIntensity(0); + assert.equal( + f.effects.sharpenStage.enabled, + false, + 'no residual sharpen at 0', + ); + f.effects.setSharpenEnabled(true); + assert.equal(f.effects.sharpenStage.enabled, false, 'on at 0 is still off'); + f.effects.applySharpenIntensity(0.2); + assert.equal(f.effects.sharpenStage.enabled, true); + f.effects.destroy(); +}); + test('stop revokes pending work immediately but retains stages until final destruction', () => { const f = fixture(); f.effects.initStyles(); From ba292ce47554a38b07dec513db5779d67aeae17f Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:58:14 -0700 Subject: [PATCH 18/20] fix(flights): keep ground-snap sampling after the clock steps back (#1399) Recover aircraft ground-height sampling after backward wall-clock changes while preserving sampling budgets. Contribution by @quanticsoul4772. --- changelog.d/1399-ground-snap-clock-step.md | 3 ++ docs/CURRENT-STATE.md | 5 ++ src/services/groundSnap.js | 14 ++++- src/services/groundSnap.test.mjs | 62 ++++++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 changelog.d/1399-ground-snap-clock-step.md create mode 100644 src/services/groundSnap.test.mjs diff --git a/changelog.d/1399-ground-snap-clock-step.md b/changelog.d/1399-ground-snap-clock-step.md new file mode 100644 index 000000000..73196986a --- /dev/null +++ b/changelog.d/1399-ground-snap-clock-step.md @@ -0,0 +1,3 @@ +- Aircraft on the ground keep getting ground-height samples after the + computer's clock steps back, instead of waiting for it to catch up + (quanticsoul4772, #1399). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e2e24fc7d..c5cb8f905 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5309,6 +5309,11 @@ pins and share links already supply complete boxes. - **The TRACKED contact's 2D↔3D handoff is DEFAULT behaviour (2026-08-19), driven by camera distance alone.** It does NOT consult the DISPLAY-rail `3D` toggle, which continues to own the FLEET (the un-instanced draw-call budget stays the operator's decision). Policy lives in `src/data/trackedModelRegime.js` and is shared by both layers: enter below `TRACKED_MODEL_ENTER_ALT_M` = 150,000 m and hand back to the billboard only above `TRACKED_MODEL_EXIT_ALT_M` = 172,500 m. **The swap distance was set by playtesting on 2026-08-20:** a first pass at 1,000,000 m switched too early; 2D reads correctly at ~600 km and the handoff belongs at ~150 km. **Consequence, recorded on purpose:** the tracked contact now enters 3D NEARER than the FLEET does (`MODEL_ALT_CEIL_M` = 800,000 m, unchanged), so with the DISPLAY-rail `3D` toggle on, 150–800 km draws surrounding contacts as models while the selected one is still a glyph. Nothing double-draws (the fleet pass skips the tracked icao) and aligning the two is a fleet-side decision, deliberately out of scope. **The two thresholds are asymmetric on purpose:** a single threshold makes a tracked orbit sitting ON the boundary strobe billboard↔model as the camera's altitude wobbles across it. Do not collapse them. The latch is scoped per selection, so a new target re-evaluates against the ENTER ceiling rather than inheriting the previous target's exit band. Exactly ONE model is involved; it loads on demand when the regime opens, is HIDDEN (not released) on regime exit so re-entry has no load gap, and is released by the existing teardown on deselect/re-track/destroy. Cockpit and TR-3B suppression are unchanged. **Two invariants around it:** (a) the hysteresis latch AND the load-failure latch are per-selection state cleared by `_resetTrackedSelectionState()` in the tracking lifecycle (deselect / re-track / cross-layer / init / destroy) — the predicate's icao-change guard is defence only, since it needs a drawn frame while nothing is selected and the render governor's idle mode does not promise one; (b) on-demand loading is bounded at 3 attempts per selection with a 1.5 s backoff and one console warning naming the asset — the driver runs every `scene.preUpdate`, so an unbounded catch means a missing GLB spins load→reject at frame rate. The billboard stays the visual throughout a failed load. - **Grounded 3D handoff is terrain-validity gated (2026-08-23).** A ready civilian or military glTF does not own the visual until `groundSnap` (`src/data/groundSnap.js`) can answer with a MEASURED photoreal-surface height. On success the layer writes `height + the model's measured belly offset` before revealing the model. Cache movement is measured on the WGS84 surface, not across altitude, so a stationary contact keeps its snap through poll-time vertical-datum changes. Model existence or GPU readiness alone never suppresses the billboard floor, and ordinary zoom/style/deselect transitions preserve a valid snap cache. **Two states, and the difference is the whole design.** COLD — nothing has ever resolved for this icao (tiles still streaming on first sight, sample failure, backoff after a first miss): there is no evidence of where the ground is, `heightFor` returns null, the model stays hidden and the depth-test-free 2D billboard remains opaque and floored. WARM — a snap resolved and then a >`MOVE_INVALIDATE_M` (50 m) taxi move stopped it answering directly: the measurement is DEMOTED to a bounded last-known rather than deleted, and it keeps answering while the resample is outstanding, so a taxiing aircraft does not pop 3D→2D→3D across a 2–30 s retry backoff. The bound is `HELD_SNAP_MAX_DRIFT_M` = 250 m from the spot the value was measured at, past which the hold is dropped rather than stretched and the contact is COLD again. It is spatial with no timer beside it (ground under a contact that has not moved does not change; what invalidates the value is the contact MOVING) and deliberately a quarter of the billboard chain's `HELD_FLOOR_MAX_DRIFT_KM` — that hold only ever RAISES a sprite, while a held snap IS the model's placement, so its error shows in both directions. A fresh sample releases the hold, and so does a ground flip (which already calls `forget`). **A loading model is HIDDEN, never zero-scaled:** admission sets `show = false` (Cesium's default is `true`, and an unplaced primitive would claim the visual at the identity matrix), and ownership is `ready && show` — Cesium 1.138's `Model.update` has no `show` guard, so hiding a primitive costs its load nothing. +- **Ground-snap clock rollback recovery.** If the wall clock moves before the + sampling-window start, ground snap opens a new window while retaining the + normal four-samples-per-250-ms budget. Retry timestamps more than the maximum + 30-second backoff ahead of the current clock are ignored. A smaller rollback + can retain a bounded retry delay of up to 30 seconds. - Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, flat amber or emergency red). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. - **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). With the class colour supplying 94% of the surface, the models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; the two preload calls only warm the glTF cache and are never drawn. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. diff --git a/src/services/groundSnap.js b/src/services/groundSnap.js index 159cd66fd..eecb1fa34 100644 --- a/src/services/groundSnap.js +++ b/src/services/groundSnap.js @@ -307,10 +307,20 @@ export function createGroundSnap({ groundFloor }) { } const now = Date.now(); const entry = entries.get(icao); - if (entry && now < entry.nextRetryMs) return heldSnapM(entry, surfacePos); // backoff in force + // Backoff in force, unless it ends further off than any backoff can: + // a retry stamped before the clock stepped back would otherwise wait for + // the clock to catch up. + if ( + entry && + now < entry.nextRetryMs && + entry.nextRetryMs - now <= RETRY_MAX_MS + ) + return heldSnapM(entry, surfacePos); // Per-window budget: deny WITHOUT a retry stamp so the overflow simply // tries again next tick instead of waiting out a backoff it didn't earn. - if (now - windowStartMs > SAMPLE_WINDOW_MS) { + // A clock that stepped back also opens a new window, or none would open + // until it caught up. + if (now < windowStartMs || now - windowStartMs > SAMPLE_WINDOW_MS) { windowStartMs = now; windowCount = 0; } diff --git a/src/services/groundSnap.test.mjs b/src/services/groundSnap.test.mjs new file mode 100644 index 000000000..8dfe42c46 --- /dev/null +++ b/src/services/groundSnap.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createGroundSnap } from './groundSnap.js'; + +const groundFloor = { + cachedMeshFloor: () => null, + meshFloorPreferred: () => false, + reportValidatedMeshFloorCell: () => {}, +}; + +/** A viewer whose ground is a loaded 3D tileset, counting height samples. */ +function makeViewer() { + const calls = { samples: 0 }; + const tileset = { show: true, tilesLoaded: true }; + return { + calls, + scene: { + primitives: { length: 1, get: () => tileset }, + sampleHeight() { + calls.samples += 1; + return 1650; + }, + globe: { getHeight: () => 1600 }, + }, + }; +} + +const atDenverAirport = Cesium.Cartesian3.fromDegrees(-104.6737, 39.8561, 0); + +test('a clock that steps back opens a new sample window instead of denying samples until it catches up', (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 }); + const viewer = makeViewer(); + const snap = createGroundSnap({ groundFloor }); + const icaos = ['a00001', 'a00002', 'a00003', 'a00004', 'a00005', 'a00006']; + for (const icao of icaos) snap.heightFor(viewer, icao, atDenverAirport); + assert.equal(viewer.calls.samples, 4, 'four samples per 250 ms window'); + t.mock.timers.setTime(1_000_000 - 60_000); + for (const icao of icaos) snap.heightFor(viewer, icao, atDenverAirport); + assert.equal( + viewer.calls.samples, + 6, + 'the two denied aircraft sample in the new window', + ); +}); + +test('a retry backoff stamped before the clock stepped back does not wait for it to catch up', (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 10_000_000 }); + const viewer = makeViewer(); + const tileset = viewer.scene.primitives.get(0); + const snap = createGroundSnap({ groundFloor }); + // Tiles still streaming: a miss, backed off for 2 s. + tileset.tilesLoaded = false; + assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), null); + tileset.tilesLoaded = true; + assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), null); + assert.equal(viewer.calls.samples, 0, 'the backoff holds'); + // The clock steps back an hour: the stamp is an hour off, past any backoff. + t.mock.timers.setTime(10_000_000 - 3_600_000); + assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), 1650); + assert.equal(viewer.calls.samples, 1); +}); From a43ff8619ea2966cd4ab51382f6d77b0f9e7379f Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 26 Oct 2026 00:04:43 +0000 Subject: [PATCH 19/20] fix(search): normalize geocoder bias across the antimeridian (#1272) Keep geocoder viewport bias near the antimeridian and omit misleading wrapped viewboxes. Contribution by @asturksever. --- changelog.d/1272-search-issue-fix.md | 1 + docs/CURRENT-STATE.md | 7 ++++++- src/keylessGeocoder.js | 25 ++++++++++++++++++++----- src/keylessGeocoder.test.mjs | 11 +++++++++++ src/nominatimGeocode.js | 4 +++- src/nominatimGeocode.test.mjs | 9 +++++++++ 6 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1272-search-issue-fix.md diff --git a/changelog.d/1272-search-issue-fix.md b/changelog.d/1272-search-issue-fix.md new file mode 100644 index 000000000..48b803534 --- /dev/null +++ b/changelog.d/1272-search-issue-fix.md @@ -0,0 +1 @@ +- Normalize geocoder bias across the antimeridian (asturksever, #1272). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c5cb8f905..8be9af204 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5197,9 +5197,14 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total deadline, with Photon requests capped at six seconds each. Caller/application cancellation stops retries and late cache writes. Replacing a location search cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five -candidates, and an unbiased retry for name mismatches. Invalid/wrapped bounds +candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers are omitted rather than framing the wrong part of the globe. +For request viewport bias, Photon unwraps antimeridian-crossing longitude bounds +before computing their midpoint, then wraps that midpoint back into ±180 degrees. +Malformed viewport bounds add no proximity bias. Nominatim omits a crossing +request viewbox and continues searching without that geographic preference. + ### Map Stack Switcher (June 2026) - `src/mapStackController.js` switches between Google Photorealistic 3D (`photoreal`, the default when a Google or ion key is present), keyless Esri World Imagery (the zero-key default landing, with keyless terrain), Bing Aerial / Aerial-with-Labels via Cesium ion world imagery (require `CESIUM_ION_TOKEN`), and OSM tile fallback. Bing Road is **retired**: it is gone from `MAP_STACKS`, from the `set_map_stack` enum, and from the voice aliases (road phrasings now resolve to OSM, the one shipped road basemap). An old `map=bing-road` link is simply an unknown id and takes `setStack()`'s existing photoreal fallback with the Google 3D tile lit — pinned live in `scripts/qa-map-source-tray.mjs`. diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index 6033a6776..7ca99a96d 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -206,12 +206,27 @@ export function photonSearchUrl( url.searchParams.set('limit', String(limit)); const corners = String(bias ?? '').split('|'); - if (corners.length === 2) { - const [swLat, swLng] = corners[0].split(',').map(Number); - const [neLat, neLng] = corners[1].split(',').map(Number); - if ([swLat, swLng, neLat, neLng].every(Number.isFinite)) { + const tokens = corners.flatMap((corner) => corner.split(',')); + if ( + corners.length === 2 && + corners.every((corner) => corner.split(',').length === 2) && + tokens.every((token) => token.trim() !== '') + ) { + const [swLat, swLng, neLat, neLng] = tokens.map(Number); + if ( + [swLat, swLng, neLat, neLng].every(Number.isFinite) && + Math.abs(swLat) <= 90 && + Math.abs(neLat) <= 90 && + swLat <= neLat && + Math.abs(swLng) <= 180 && + Math.abs(neLng) <= 180 + ) { + const midpoint = (swLng + neLng + (swLng > neLng ? 360 : 0)) / 2; url.searchParams.set('lat', String((swLat + neLat) / 2)); - url.searchParams.set('lon', String((swLng + neLng) / 2)); + url.searchParams.set( + 'lon', + String(midpoint > 180 ? midpoint - 360 : midpoint), + ); } } return url.toString(); diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index 2ce8a70b4..1a702fd02 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -452,3 +452,14 @@ function readGeocodingConsumer(file) { ? [file, 'annotations/resolver.js'] : [file]; return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n'); } + + +test('wrapped view bias stays near the antimeridian and malformed coordinates are omitted', () => { + for (const [bias, expected] of [['-20,170|-10,-171', 179.5], ['50,175|60,-165', -175], ['0,180|10,-180', 180], ['0,-180|10,180', 0]]) { + const params = new URL(photonSearchUrl('island', { bias })).searchParams; + assert.equal(Number(params.get('lon')), expected); + } + for (const bias of ['-91,0|0,1', '0,181|1,0', '10,0|0,1', ',|,', '0,1,2|3,4', '0,1|Infinity,2']) { + assert.equal(new URL(photonSearchUrl('island', { bias })).searchParams.has('lon'), false); + } +}); diff --git a/src/nominatimGeocode.js b/src/nominatimGeocode.js index b7dd6145a..4a5a1948e 100644 --- a/src/nominatimGeocode.js +++ b/src/nominatimGeocode.js @@ -166,6 +166,8 @@ export function nominatimViewboxFromBounds(bounds) { const neLat = nominatimLatitude(match[3]); const neLng = nominatimLongitude(match[4]); if ([swLat, swLng, neLat, neLng].some((value) => value === null)) return null; - if (swLat >= neLat) return null; + // One Nominatim viewbox cannot express a wrapped interval. Omit this soft + // preference rather than biasing the search toward the opposite hemisphere. + if (swLat >= neLat || swLng > neLng) return null; return `${swLng},${neLat},${neLng},${swLat}`; } diff --git a/src/nominatimGeocode.test.mjs b/src/nominatimGeocode.test.mjs index b139ad820..8517649d5 100644 --- a/src/nominatimGeocode.test.mjs +++ b/src/nominatimGeocode.test.mjs @@ -173,3 +173,12 @@ test('a viewport hint is sent only when every edge is a real coordinate', () => `${JSON.stringify(bounds)} must not become a viewbox`, ); }); + +test('wrapped viewports omit the Nominatim bias without rejecting the search', () => { + assert.equal(nominatimViewboxFromBounds('-20,170|-10,-171'), null); + assert.equal(nominatimViewboxFromBounds('50,175|60,-165'), null); + assert.equal( + nominatimViewboxFromBounds('-20,170|-10,180'), + '170,-10,180,-20', + ); +}); From 2006c035d9e14e63f8a3942c3d2694988a08f316 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 17:21:41 -0700 Subject: [PATCH 20/20] chore: remove four modules nothing in the app uses (#1403) * chore: remove four modules nothing in the app uses src/app/sourceSlot.js re-exported src/sources/sourceSlot.js, which every caller imports directly. src/data/flowTiles.js and src/data/issPass.js were reached only by their own tests. src/data/localGeojson.js wrapped localGeojsonCore.js with the app's services, which the catalog already passes itself. The local GeoJSON layer tests now build layers the way the catalog does, so they keep covering localGeojsonCore.js; only the test of the wrapper's own default host goes. Boundary lists, format scope and docs drop the removed paths. * docs: changelog for #1403 --- changelog.d/1403-remove-unused-modules.md | 5 + docs/CODE-BOUNDARIES.md | 2 - docs/CURRENT-STATE.md | 2 +- docs/INFRASTRUCTURE-LAYERS.md | 5 +- scripts/check-import-directions.mjs | 8 - scripts/format-scope.json | 2 - scripts/package-boundaries.json | 6 - src/app/sourceSlot.js | 2 - src/app/sourceSlot.test.mjs | 24 --- src/data/flowTiles.js | 6 - src/data/flowTiles.test.mjs | 162 ------------------- src/data/issPass.js | 46 ------ src/data/issPass.test.mjs | 42 ----- src/data/localGeojson.analyst.test.mjs | 13 +- src/data/localGeojson.js | 29 ---- src/data/localGeojson.test.mjs | 23 ++- src/data/localGeojsonLifecycle.test.mjs | 10 +- src/data/localGeojsonLod.js | 8 +- src/data/tomtomTiles.js | 2 +- src/layers/traffic/flowDecode.js | 2 +- src/overlays/worldOverlayAllocation.test.mjs | 2 +- 21 files changed, 46 insertions(+), 355 deletions(-) create mode 100644 changelog.d/1403-remove-unused-modules.md delete mode 100644 src/app/sourceSlot.js delete mode 100644 src/app/sourceSlot.test.mjs delete mode 100644 src/data/flowTiles.js delete mode 100644 src/data/flowTiles.test.mjs delete mode 100644 src/data/issPass.js delete mode 100644 src/data/issPass.test.mjs delete mode 100644 src/data/localGeojson.js diff --git a/changelog.d/1403-remove-unused-modules.md b/changelog.d/1403-remove-unused-modules.md new file mode 100644 index 000000000..da95c7dcd --- /dev/null +++ b/changelog.d/1403-remove-unused-modules.md @@ -0,0 +1,5 @@ +- Remove four modules nothing in the app imports: `src/app/sourceSlot.js`, + `src/data/flowTiles.js`, `src/data/issPass.js` and `src/data/localGeojson.js`. + The local GeoJSON layer tests now build layers the way the catalog does, so + they keep covering `localGeojsonCore.js`. Package exports are unchanged + (samehkhamis, #1403). diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 9085a7b72..9f8e0f629 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -81,8 +81,6 @@ moves it. Do not label all ingestion modules platform-independent by filename. | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `src/data/manager.js` | Lifecycle/panel compatibility and existing tests; normal assembly constructs lifecycle and presentation separately | | `src/data/.js` | Legacy default instances; new assembly calls layer factories with supplied sources | -| `src/data/localGeojson.js` | Infrastructure compatibility factories; catalog imports services from `src/app/localGeojsonServices.js` directly | -| `src/app/sourceSlot.js` | Re-export for direct callers/tests; portable slot ownership is `src/sources/sourceSlot.js` | | `src/app/sources.js`, `src/services/application.js` | Existing default/override API; current application construction supplies its own instances | | `server/providers/local.js` | Existing Node composition barrel and provider helper re-exports | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8be9af204..3c53c93a2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4662,7 +4662,7 @@ current route throughout enabling, enabled, disabling, and uncertain lifecycle renders. Both routes preserve Radio power, playback, station, filter, and volume state. -Bundled datasets live in `src/data/local_data/` with per-folder provenance READMEs; they are lazily loaded via `src/data/localGeojson.js` (Vite `?url` assets) when toggled on. +Bundled datasets live in `src/data/local_data/` with per-folder provenance READMEs; they are lazily loaded via `src/data/localGeojsonCore.js` (Vite `?url` assets) when toggled on. A bundled dataset that fails to load is a broken install, not a blip, so it is never swallowed: `localGeojson.js` guards `response.ok`, reports `error` + diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md index 76567e605..31d3cb4cb 100644 --- a/docs/INFRASTRUCTURE-LAYERS.md +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -53,9 +53,8 @@ Cesium installation shared with its viewer; it must not bundle separate copies. `gods-eye-view/infrastructure/geojson` exports the lower-level `createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers. -The standalone app's `src/data/localGeojson.js` keeps its original single-argument -factory and supplies its existing functions. Other consumers should use the package -exports, which do not import standalone application globals. +Consumers use the package exports, which do not import standalone application +globals. Dataset files and source/license notices remain under `src/data/local_data/`. Their input counts are 4,351 datacenter features and 704 dam features. Cesium may diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 3f4fec617..b6d8dba67 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -153,14 +153,6 @@ export function checkImportDirections(root) { file, 'Actions must consume feed state without the manager facade', ); - if ( - file === 'src/app/constructCatalog.js' && - to === 'src/data/localGeojson.js' - ) - report( - file, - 'Catalog must use the services owner without compatibility layer construction', - ); } return record; } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f88be2b6b..d62e8bb47 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -518,7 +518,6 @@ "src/layers/bikeshare/selection.js", "src/data/traffic.js", "src/data/bikeshare.js", - "src/data/flowTiles.js", "src/layers/traffic/source.test.mjs", "src/layers/bikeshare/source.test.mjs", "src/layers/bikeshare/proxyRoute.test.mjs", @@ -651,7 +650,6 @@ "src/agent/worldPacket.test.mjs", "src/voice/layerSummary.js", "build/application-html.js", - "src/app/sourceSlot.js", "src/app/sources.js", "src/ui/composition.js", "src/services/application.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index cddb29c40..89589091a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1163,7 +1163,6 @@ "satellites-layer": { "exports": ["./layers/satellites"], "modules": [ - "src/data/issPass.js", "src/data/satelliteClass.js", "src/data/satellitePass.js", "src/sources/tle.js", @@ -1853,14 +1852,12 @@ "src/data/iconOrientation.js", "src/data/infrastructure.js", "src/data/installationFeedback.js", - "src/data/issPass.js", "src/data/labelArbiter.js", "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/lifecycle.js", - "src/data/localGeojson.js", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", "src/data/local_data/natural_earth/marine.json", @@ -2672,13 +2669,11 @@ "src/data/iconOrientation.js", "src/data/infrastructure.js", "src/data/installationFeedback.js", - "src/data/issPass.js", "src/data/satellitePass.js", "src/data/labelArbiter.js", "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", - "src/data/localGeojson.js", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", "src/data/local_data/natural_earth/marine.json", @@ -3663,7 +3658,6 @@ "exports": ["./layers/transit/standalone"], "modules": [ "src/cyberSonar.js", - "src/app/sourceSlot.js", "src/celestialRing.js", "src/data/approxDistance.js", "src/data/cameraSensitivity.js", diff --git a/src/app/sourceSlot.js b/src/app/sourceSlot.js deleted file mode 100644 index fe7590e3b..000000000 --- a/src/app/sourceSlot.js +++ /dev/null @@ -1,2 +0,0 @@ -// Compatibility entry; source contracts are owned by sources/. -export { createSourceSlot } from '../sources/sourceSlot.js'; diff --git a/src/app/sourceSlot.test.mjs b/src/app/sourceSlot.test.mjs deleted file mode 100644 index 1b1face0c..000000000 --- a/src/app/sourceSlot.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createSourceSlot } from './sourceSlot.js'; - -test('replacing a source rejects its late result and old cleanup cannot clear the new source', async () => { - let finish; - const slot = createSourceSlot({ read: async () => 'default' }, ['read']); - const old = slot.configure({ read: () => new Promise(resolve => { finish = resolve; }) }); - const pending = slot.source.read(); - const stop = slot.configure({ read: async () => 'replacement', attribution: { name: 'Replacement' } }); - old(); - finish('stale'); - await assert.rejects(pending, { name: 'AbortError' }); - assert.equal(await slot.source.read(), 'replacement'); - assert.equal(slot.source.attribution.name, 'Replacement'); - stop(); - assert.throws(() => slot.source.read(), /not configured/); -}); - -test('an invalid replacement leaves the working source intact', async () => { - const slot = createSourceSlot({ read: async () => 42 }, ['read']); - assert.throws(() => slot.configure({}), TypeError); - assert.equal(await slot.source.read(), 42); -}); diff --git a/src/data/flowTiles.js b/src/data/flowTiles.js deleted file mode 100644 index 07efb0d9a..000000000 --- a/src/data/flowTiles.js +++ /dev/null @@ -1,6 +0,0 @@ -import { createFlowTileSource } from '../layers/traffic/flowSource.js'; -export { tilesForBounds } from './tomtomTiles.js'; -export { decodeFlowTile } from '../layers/traffic/flowDecode.js'; -const source = createFlowTileSource(); -export const { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } = - source; diff --git a/src/data/flowTiles.test.mjs b/src/data/flowTiles.test.mjs deleted file mode 100644 index 61a7ddb78..000000000 --- a/src/data/flowTiles.test.mjs +++ /dev/null @@ -1,162 +0,0 @@ -// src/data/flowTiles.test.mjs -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - decodeFlowTile, - fetchFlowForBounds, - tilesForBounds, - getFlowSessionStats, - resetFlowTileCache, -} from './flowTiles.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Real TomTom flow tile, downtown Austin z12 x935 y1686 (probed live 2026-07-16). -const FIXTURE = path.join(__dirname, 'fixtures', 'tomtom-flow-austin-12-935-1686.pbf'); -const FIXTURE_TILE = { z: 12, x: 935, y: 1686 }; - -function loadFixture() { - return fs.readFileSync(FIXTURE); -} - -// ── decodeFlowTile against the real fixture ───────────────── - -test('fixture decode: more than 50 flow segments', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - assert.ok(segments.length > 50, `got ${segments.length}`); -}); - -test('fixture decode: every trafficLevel is within [0, 1]', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - for (const s of segments) { - assert.ok(Number.isFinite(s.trafficLevel), `non-finite level: ${s.trafficLevel}`); - assert.ok(s.trafficLevel >= 0 && s.trafficLevel <= 1, `level out of range: ${s.trafficLevel}`); - } -}); - -test('fixture decode: all coordinates land in downtown Austin', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - for (const s of segments) { - assert.ok(Array.isArray(s.coords) && s.coords.length >= 2, 'polyline too short'); - for (const [lon, lat] of s.coords) { - assert.ok(lon >= -98.0 && lon <= -97.5, `lon out of Austin range: ${lon}`); - assert.ok(lat >= 30.0 && lat <= 30.5, `lat out of Austin range: ${lat}`); - } - } -}); - -test('fixture decode: congestion exists (at least one trafficLevel < 1)', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - assert.ok(segments.some((s) => s.trafficLevel < 1), 'no congested segment found'); -}); - -test('fixture decode: segment shape is {coords, trafficLevel, roadType, closure}', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - for (const s of segments) { - assert.equal(typeof s.roadType, 'string'); - assert.equal(typeof s.closure, 'boolean'); - } - // The fixture carries real closures — closure decoding is exercised, not vacuous. - assert.ok(segments.some((s) => s.closure === true), 'expected at least one closure in fixture'); -}); - -test('decode of a non-MVT buffer returns [] (defensive)', () => { - assert.deepEqual(decodeFlowTile(Buffer.from('not a protobuf tile'), 12, 935, 1686), []); -}); - -// ── tilesForBounds (re-exported slippy math) ──────────────── - -test('tilesForBounds: 30.2672,-97.7431 @ z12 -> covers x935 y1686', () => { - const tiles = tilesForBounds({ - south: 30.2672 - 0.001, north: 30.2672 + 0.001, - west: -97.7431 - 0.001, east: -97.7431 + 0.001, - }, 12); - assert.ok( - tiles.some((t) => t.z === 12 && t.x === 935 && t.y === 1686), - `fixture tile missing: ${JSON.stringify(tiles)}` - ); -}); - -// ── fetchFlowForBounds (stubbed fetch: cache + abort) ─────── - -/** Bounds fully inside the fixture tile. */ -const FIXTURE_BOUNDS = { south: 30.24, north: 30.26, west: -97.76, east: -97.74 }; - -function stubFetch(impl) { - const original = globalThis.fetch; - globalThis.fetch = impl; - return () => { globalThis.fetch = original; }; -} - -test('fetchFlowForBounds: fetches covering tiles via /api/tomtom and decodes', async () => { - resetFlowTileCache(); - const calls = []; - const restore = stubFetch(async (url) => { - calls.push(String(url)); - return new Response(loadFixture(), { - status: 200, - headers: { 'Content-Type': 'application/x-protobuf' }, - }); - }); - try { - const segments = await fetchFlowForBounds(FIXTURE_BOUNDS); - assert.equal(calls.length, 1); - assert.match(calls[0], /^\/api\/tomtom\/flow\/12\/935\/1686\.pbf$/); - assert.ok(segments.length > 50); - assert.ok(getFlowSessionStats().tilesFetched >= 1); - } finally { - restore(); - } -}); - -test('fetchFlowForBounds: decode cache serves repeat calls within TTL (no refetch)', async () => { - resetFlowTileCache(); - let calls = 0; - const restore = stubFetch(async () => { - calls += 1; - return new Response(loadFixture(), { status: 200 }); - }); - try { - const first = await fetchFlowForBounds(FIXTURE_BOUNDS); - const second = await fetchFlowForBounds(FIXTURE_BOUNDS); - assert.equal(calls, 1, 'second call must be served from the decode cache'); - assert.equal(second.length, first.length); - } finally { - restore(); - } -}); - -test('fetchFlowForBounds: aborted signal rejects (AbortSignal-aware)', async () => { - resetFlowTileCache(); - const restore = stubFetch(async (url, opts) => { - // Mimic real fetch abort semantics. - if (opts?.signal?.aborted) { - const err = new Error('aborted'); - err.name = 'AbortError'; - throw err; - } - return new Response(loadFixture(), { status: 200 }); - }); - try { - const controller = new AbortController(); - controller.abort(); - await assert.rejects( - fetchFlowForBounds(FIXTURE_BOUNDS, { signal: controller.signal }), - (err) => err.name === 'AbortError' - ); - } finally { - restore(); - } -}); - -test('fetchFlowForBounds: non-OK tile responses reject when nothing succeeds', async () => { - resetFlowTileCache(); - const restore = stubFetch(async () => new Response(JSON.stringify({ error: 'no_key' }), { status: 503 })); - try { - await assert.rejects(fetchFlowForBounds(FIXTURE_BOUNDS)); - } finally { - restore(); - } -}); diff --git a/src/data/issPass.js b/src/data/issPass.js deleted file mode 100644 index c3807d59f..000000000 --- a/src/data/issPass.js +++ /dev/null @@ -1,46 +0,0 @@ -// src/data/issPass.js -/** - * Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the - * original findNextIssPass signature and defaults still work. - */ -import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js'; - -export { lookAnglesAt }; - -/** - * Predict the next ISS pass over an observer location. - * @param {Object} options - * @param {Object} options.satrec SGP4 satellite record - * @param {number} options.latDeg Observer latitude in degrees [-90, 90] - * @param {number} options.lonDeg Observer longitude in degrees [-180, 180] - * @param {number} options.fromMs UTC start time in milliseconds - * @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees - * @param {number} [options.horizonHours=24] Maximum search window in hours - * @param {number} [options.coarseStepSec=30] Coarse search step in seconds - * @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility - * @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass - * @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null} - */ -export function findNextIssPass({ - satrec, - latDeg, - lonDeg, - fromMs, - minElevDeg = 10, - horizonHours = 24, - coarseStepSec = 30, - fineStepSec = 5, - requireVisible = false, -}) { - return findNextSatellitePass({ - satrec, - latDeg, - lonDeg, - fromMs, - minElevDeg, - horizonHours, - coarseStepSec, - fineStepSec, - requireVisible, - }); -} diff --git a/src/data/issPass.test.mjs b/src/data/issPass.test.mjs deleted file mode 100644 index aaaa87a55..000000000 --- a/src/data/issPass.test.mjs +++ /dev/null @@ -1,42 +0,0 @@ -// src/data/issPass.test.mjs -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { twoline2satrec } from 'satellite.js'; -import { findNextIssPass, lookAnglesAt } from './issPass.js'; -import { findNextSatellitePass } from './satellitePass.js'; - -// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC). -const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927'; -const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537'; -const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 }; -const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0); // just after the TLE epoch - -test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', () => { - const satrec = twoline2satrec(L1, L2); - const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 }); - assert.ok(pass, 'expected a pass within 24h at 30°N for a 51.6° inclination orbit'); - assert.ok(pass.riseMs > FROM_MS); - assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs); - assert.ok(pass.maxElevDeg >= 10); - assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360); - - // Independent re-check: elevation at the reported peak really is the max-ish - // and ≥ threshold; one minute before rise it is below threshold. - const peak = lookAnglesAt(satrec, pass.maxElevMs, AUSTIN.latDeg, AUSTIN.lonDeg); - assert.ok(peak && Math.abs(peak.elevDeg - pass.maxElevDeg) < 1.5); - const before = lookAnglesAt(satrec, pass.riseMs - 60_000, AUSTIN.latDeg, AUSTIN.lonDeg); - assert.ok(before && before.elevDeg < 10); -}); - -test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => { - const satrec = twoline2satrec(L1, L2); - const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 }; - assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 })); - assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 })); -}); - -test('returns null when no pass clears an absurd threshold', () => { - const satrec = twoline2satrec(L1, L2); - const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 }); - assert.equal(pass, null); -}); diff --git a/src/data/localGeojson.analyst.test.mjs b/src/data/localGeojson.analyst.test.mjs index 061a7f27c..33d35a039 100644 --- a/src/data/localGeojson.analyst.test.mjs +++ b/src/data/localGeojson.analyst.test.mjs @@ -2,7 +2,18 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { createLocalGeoJsonLayer, mapAnalystRecord } from './localGeojson.js'; +import { localGeoJsonServices } from '../app/localGeojsonServices.js'; +import { + createLocalGeoJsonLayer as createLayer, + mapAnalystRecord, +} from './localGeojsonCore.js'; + +/** The catalog's services, with the overlay host each test supplies. */ +const createLocalGeoJsonLayer = (options) => + createLayer(options, { + ...localGeoJsonServices, + overlayHost: options.overlayHost, + }); const DC_RAW = { id: '1176042553', diff --git a/src/data/localGeojson.js b/src/data/localGeojson.js deleted file mode 100644 index 61f6c3abf..000000000 --- a/src/data/localGeojson.js +++ /dev/null @@ -1,29 +0,0 @@ -import { localGeoJsonServices } from '../app/localGeojsonServices.js'; -import { - createLocalGeoJsonLayer as createLayer, - createLocalInfrastructureOverlayPublisher as createPublisher, -} from './localGeojsonCore.js'; - -export * from './localGeojsonCore.js'; - -/** Create a layer using the standalone app's operations; retain overlay overrides. */ -export function createLocalGeoJsonLayer(options) { - return createLayer(options, { - ...localGeoJsonServices, - overlayHost: - options.overlayHost === undefined - ? localGeoJsonServices.overlayHost - : options.overlayHost, - }); -} - -/** Create an overlay publisher using the standalone app's host by default. */ -export function createLocalInfrastructureOverlayPublisher(options) { - return createPublisher({ - ...options, - host: - options.host === undefined - ? localGeoJsonServices.overlayHost - : options.host, - }); -} diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 1c4499914..13cde65a5 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -6,13 +6,14 @@ import { GROUND_SAMPLE_MAX_ARMED_RETRIES, LOCAL_OVERLAY_COHORT_LIMIT, LOCAL_STEM_TIP_EPSILON_M, - createLocalGeoJsonLayer, + createLocalGeoJsonLayer as createLayer, createLocalInfrastructureOverlayEntry, createLocalInfrastructureOverlayPublisher, localDatasetError, localInfrastructureOverlayCopy, selectLocalInfrastructureOverlayCohort, -} from './localGeojson.js'; +} from './localGeojsonCore.js'; +import { localGeoJsonServices } from '../app/localGeojsonServices.js'; import { layerFeedState } from './manager.js'; import { INFRA_LOD_ACTIVE_MIN, INFRA_LOD_ACTIVE_MAX } from './localGeojsonLod.js'; import { @@ -21,6 +22,13 @@ import { _resetRenderGovernorForTest, } from '../renderGovernor.js'; +/** The catalog's services, with the overlay host each test supplies. */ +const createLocalGeoJsonLayer = (options) => + createLayer(options, { + ...localGeoJsonServices, + overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost, + }); + class MockLayerEvent { constructor() { this.listeners = new Set(); @@ -1337,14 +1345,3 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects'); }); - -test('standalone publisher retains its default host for an undefined override', () => { - const publisher = createLocalInfrastructureOverlayPublisher({ - sourceId: 'local-default-host-test', host: undefined, - }); - assert.doesNotThrow(() => { - publisher.show(); - publisher.hide(); - publisher.destroy(); - }); -}); diff --git a/src/data/localGeojsonLifecycle.test.mjs b/src/data/localGeojsonLifecycle.test.mjs index 245867155..8b5621ec4 100644 --- a/src/data/localGeojsonLifecycle.test.mjs +++ b/src/data/localGeojsonLifecycle.test.mjs @@ -1,9 +1,17 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { createLocalGeoJsonLayer } from './localGeojson.js'; +import { localGeoJsonServices } from '../app/localGeojsonServices.js'; +import { createLocalGeoJsonLayer as createLayer } from './localGeojsonCore.js'; import { getContextStore } from './contextStore.js'; +/** The catalog's services, with the overlay host each test supplies. */ +const createLocalGeoJsonLayer = (options) => + createLayer(options, { + ...localGeoJsonServices, + overlayHost: options.overlayHost, + }); + const dataset = JSON.stringify({ type: 'Feature', id: 'dam', properties: { name: 'Test dam' }, geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] }, diff --git a/src/data/localGeojsonLod.js b/src/data/localGeojsonLod.js index cfd082c04..4a2dd92da 100644 --- a/src/data/localGeojsonLod.js +++ b/src/data/localGeojsonLod.js @@ -5,7 +5,7 @@ * Pure selection policy for the bundled local-infrastructure layers * (`local-datacenters`, `local-dams`, and any future `createLocalGeoJsonLayer` * dataset). Cesium-specific projection, occlusion, and stem geometry stay in - * `localGeojson.js`; this module turns the resulting in-view records into a + * `localGeojsonCore.js`; this module turns the resulting in-view records into a * bounded "active" set — the only records that layer should spend per-frame * stem-geometry and ground-sample work on. * @@ -80,7 +80,7 @@ export const INFRA_LOD_INCUMBENT_BONUS = 250; /** * Distance at or beyond which the proximity penalty is fully applied (m). - * Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in localGeojson.js. + * Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in infrastructureOverlayEntry.js. */ export const INFRA_LOD_FAR_M = 12_000_000; @@ -97,7 +97,7 @@ export const INFRA_LOD_MAX_DISTANCE_PENALTY = 200; * (callers only compare it), but every input is normalized so the output is * always a finite number. * - * @param {number} priority Existing label-priority score (localGeojson.js). + * @param {number} priority Existing label-priority score (localGeojsonCore.js). * @param {number} distanceM Camera→feature distance in metres. * @param {boolean} isIncumbent Record currently holds an active stem. * @param {object} [options] @@ -297,7 +297,7 @@ export function applyInfraEvictionGrace({ * ------------------------------------------------------------------ */ /** - * Probe window for the motion fallback. `localGeojson.js` marks its selection + * Probe window for the motion fallback. `localGeojsonCore.js` marks its selection * dirty on `moveEnd`, which covers drags, one-shot `setView`, and voice * `flyTo` — but a tracked-entity follow camera, a Cockpit view, a route * flight, or a continuous orbit never emits one. Without a fallback the diff --git a/src/data/tomtomTiles.js b/src/data/tomtomTiles.js index af4fc95b3..26ec539ab 100644 --- a/src/data/tomtomTiles.js +++ b/src/data/tomtomTiles.js @@ -2,7 +2,7 @@ * @file Pure tile math + budget accounting for the TomTom traffic-flow proxy. * * Shared by the `/api/tomtom` vite plugin (server-side: coordinate validation, - * daily budget governor) and `src/data/flowTiles.js` (client-side: which tiles + * daily budget governor) and `src/layers/traffic/flowSource.js` (client-side: which tiles * cover the current traffic fetch bounds). Zero dependencies, Cesium-free, so * both sides can unit-test against it with node:test. * diff --git a/src/layers/traffic/flowDecode.js b/src/layers/traffic/flowDecode.js index 691496554..9a0c248dd 100644 --- a/src/layers/traffic/flowDecode.js +++ b/src/layers/traffic/flowDecode.js @@ -19,7 +19,7 @@ import { VectorTile } from '@mapbox/vector-tile'; * tree-shakeable; decoding happens client-side so the proxy stays a dumb * binary cache. * - * @module data/flowTiles + * @module layers/traffic/flowDecode */ const FLOW_LAYER_NAME = 'Traffic flow'; diff --git a/src/overlays/worldOverlayAllocation.test.mjs b/src/overlays/worldOverlayAllocation.test.mjs index e29785d48..2ff6d6c8c 100644 --- a/src/overlays/worldOverlayAllocation.test.mjs +++ b/src/overlays/worldOverlayAllocation.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { LOCAL_OVERLAY_COHORT_LIMIT } from '../data/localGeojson.js'; +import { LOCAL_OVERLAY_COHORT_LIMIT } from '../data/localGeojsonCore.js'; import { FIRMS_AMBIENT_COHORT_LIMIT } from '../data/firmsLabels.js'; import { vesselOverlayCohortLimit } from '../data/vesselLabels.js'; import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js';