Merge branch 'main' into upstream/terrain-429-pause

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-10-25 17:21:03 -07:00
commit 392c9989eb
16 files changed
+299 -14

No files matched your search

@@ -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).
+1
View File
@@ -0,0 +1 @@
- Normalize geocoder bias across the antimeridian (asturksever, #1272).
+2
View File
@@ -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).
@@ -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).
+18 -2
View File
@@ -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,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`.
@@ -5307,6 +5318,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.
+20 -5
View File
@@ -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();
+11
View File
@@ -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);
}
});
+3 -1
View File
@@ -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}`;
}
+9
View File
@@ -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',
);
});
+12 -2
View File
@@ -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;
}
+62
View File
@@ -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);
});
+17 -3
View File
@@ -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,
+67
View File
@@ -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);
+40
View File
@@ -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',
+13 -1
View File
@@ -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');
}
+18
View File
@@ -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();