diff --git a/changelog.d/1232-gbfs-invalid-coordinates.md b/changelog.d/1232-gbfs-invalid-coordinates.md new file mode 100644 index 000000000..4f3da2a97 --- /dev/null +++ b/changelog.d/1232-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, #1232). 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/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/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 529ffb6e6..0ca016713 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 @@ -1531,7 +1533,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 @@ -5195,9 +5201,15 @@ candidates, and an unbiased retry for name mismatches. A "place, region" query only accepts a candidate named exactly for the place whose city, county, state, country or country code (or USA/UK) contains the region; nearby results that fail that check are retried unbiased, and a nearby place whose whole name is the -query (one with a comma in it) is the last choice. Invalid/wrapped bounds +query (one with a comma in it) is the last choice. 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`. @@ -5307,6 +5319,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/keylessGeocoder.js b/src/keylessGeocoder.js index 2712dd423..d2b6b7f45 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 7759d5226..b7078d9d0 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -583,3 +583,14 @@ test('a nearby place whose own name has a comma still wins when nothing matches assert.match(result.label, /Denver/); assert.equal(fetchImpl.urls.length, 2, 'the region checks ran first'); }); + + +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', + ); +}); 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); +}); 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 676351b6c..0ea882727 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', 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();