diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..f6e61360f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5810,9 +5810,11 @@ easier to meet (detection is now on more often), but does not create it. OSM choice), so Hybrid fill is never replayed as OSM roads. Failed flow refreshes clear old matches and report simulation. `flowCoveragePct` is the percentage of shown dots on roads with flow, including all unmatched dots in - the denominator and excluding dots hidden by closures. The OSM keyed status - reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`. Road fetch - coverage follows a 5×5 ellipsoid sample of the central screen square. + the denominator and excluding dots hidden by closures. It reads 1–99 whenever + both matched and simulated dots are shown, so rounding never turns a few + matches into `(no matches)` or a few simulated dots into full coverage. The + OSM keyed status reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`. + Road fetch coverage follows a 5×5 ellipsoid sample of the central screen square. Horizon rays are capped at 10 km from nadir (reduced if necessary to fit 16 z12 tiles); polygon-intersecting tiles load nearest first. Up to 16 z14 tiles provide near detail, with z12 roads beyond them. Cached tiles serve diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js index d33e5a076..1a8eea4d6 100644 --- a/src/layers/traffic/controls.js +++ b/src/layers/traffic/controls.js @@ -259,7 +259,8 @@ export function createControls({ state: layerState, services, parts, source }) { * `error` carries this instant's health, so a live-configured layer whose * flow feed went down reads DEGRADED with the reason instead of a stale * LIVE coverage number. `flowCoveragePct` is shown dots on matched roads / - * all shown dots (0–100 int), excluding closures; `tilesFetched` counts flow-tile requests + * all shown dots (0–100 int, kept within 1–99 while both kinds are shown), + * excluding closures; `tilesFetched` counts flow-tile requests * issued to the proxy this session (decode-cache hits excluded). * @returns {{count:number, lastUpdate:number|null, loading:boolean, * mode:'live'|'sim', error:string|null, flowCoveragePct:number, @@ -304,8 +305,18 @@ export function createControls({ state: layerState, services, parts, source }) { Boolean(layerState._heightPending); const { free, slow, jam, sim } = layerState._bucketCounts; const matched = free + slow + jam; + // The label reads 0 as "no matches" and 100 as nothing simulated, so + // rounding must not reach either while both kinds of dot are shown. const flowCoveragePct = - matched + sim > 0 ? Math.round((100 * matched) / (matched + sim)) : 0; + matched + sim > 0 + ? Math.min( + sim > 0 ? 99 : 100, + Math.max( + matched > 0 ? 1 : 0, + Math.round((100 * matched) / (matched + sim)), + ), + ) + : 0; const feed = parts.model.trafficFeedPresentation({ liveMode: layerState._liveMode, fetching: loading, diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index 927dc416e..51b24b7df 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -516,6 +516,26 @@ test('late closures hide and stop dots; coverage counts shown matched dots, and assert.equal(controls.getStats().flowCoveragePct, 80); }); +test('coverage never rounds to "no matches" or full coverage while both kinds of dot are shown', async () => { + const { state, controls } = await matchingContext(async () => []); + state._bucketCounts = { free: 1, slow: 0, jam: 0, sim: 300 }; + assert.equal(controls.getStats().flowCoveragePct, 1); + assert.equal( + controls.getStats().loadingLabel, + 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 1% cov · Unmatched: simulated', + ); + state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 1 }; + assert.equal(controls.getStats().flowCoveragePct, 99); + assert.equal( + controls.getStats().loadingLabel, + 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 99% cov · Unmatched: simulated', + ); + state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 0 }; + assert.equal(controls.getStats().flowCoveragePct, 100); + state._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 300 }; + assert.equal(controls.getStats().flowCoveragePct, 0); +}); + test('refresh failure removes cached matches and reports simulated flow', async () => { const { flow, state, model, controls } = await matchingContext(async () => { throw new Error('HTTP 429');