From b096f73662a55646c02303e315937b094041a3cd Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 28 Jul 2026 04:48:39 +0200 Subject: [PATCH] fix: Areas tab Position-Fix Coverage Gaps table was overwhelming -- sort worst-first, collapse to top 10 dborup: the full 36-row dump buried the handful of areas with an actual gap behind dozens already fully GPS-mapped (0% estimated). Sort by % estimated descending and collapse to top 10 with a "Show all" toggle, matching the existing pattern from the Wardriving tab's Top Senders / Coverage by Observer sections. Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 68 +++++++++++++++++++++++++++++-------- test-analytics-areas-tab.js | 40 ++++++++++++++++++++++ 2 files changed, 93 insertions(+), 15 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 843fbe34..e0f384a0 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -6586,20 +6586,42 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '' + bridgeRows + '' : '

No packet-derived neighbor edges cross between two different areas yet.

'; - var gapRows = positionGaps.map(function (g) { - var total = g.realFix + g.approximated; - return '' + - '' + esc(g.label) + '' + - '' + g.realFix.toLocaleString() + '' + - '' + g.approximated.toLocaleString() + '' + - '' + pct(g.approximated, total) + '' + - ''; - }).join(''); - var gapHtml = positionGaps.length ? - '' + + // Sorted worst-coverage-first (highest % estimated) rather than the + // API's realFix-desc order — most areas have 0% estimated (fully + // GPS-mapped already), so a plain dump buried the handful of areas + // that actually have a gap worth looking at. Collapses to the top + // 10 by default, same "Show all N" pattern as the Wardriving tab's + // Top Senders/Coverage by Observer sections. + var AREAS_TOP_N = 10; + var sortedGaps = positionGaps.slice().sort(function (a, b) { + var totalA = a.realFix + a.approximated, totalB = b.realFix + b.approximated; + var pctA = totalA ? a.approximated / totalA : 0; + var pctB = totalB ? b.approximated / totalB : 0; + return pctB - pctA; + }); + function gapsToggleHtml(expanded) { + if (sortedGaps.length <= AREAS_TOP_N) return ''; + var label = expanded ? 'Show fewer' : 'Show all ' + sortedGaps.length.toLocaleString() + ' areas'; + return '
'; + } + function gapsTableHtml(expanded) { + if (!sortedGaps.length) { + return '

No unpositioned node could be placed in any area, even approximately.

'; + } + var shown = expanded ? sortedGaps : sortedGaps.slice(0, AREAS_TOP_N); + var rows = shown.map(function (g) { + var total = g.realFix + g.approximated; + return '' + + '' + + '' + + '' + + '' + + ''; + }).join(''); + return '
' + esc(g.label) + '' + g.realFix.toLocaleString() + '' + g.approximated.toLocaleString() + '' + pct(g.approximated, total) + '
' + '' + - '' + gapRows + '
AreaReal GPS FixEstimated (via Neighbors)% Estimated
' : - '

No unpositioned node could be placed in any area, even approximately.

'; + '' + rows + '' + gapsToggleHtml(expanded); + } var unpositionedNote = '

' + (d.unpositionedTotal || 0).toLocaleString() + ' node' + (d.unpositionedTotal === 1 ? '' : 's') + ' network-wide have no real GPS fix' + (d.unpositionedNoNeighborFix ? ', of which ' + d.unpositionedNoNeighborFix.toLocaleString() + ' also have no positioned neighbor to estimate from — those can\'t be placed anywhere, not even approximately, so they\'re absent from the table above entirely.' : '.') + @@ -6618,10 +6640,26 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '' + '

' + '

Position-Fix Coverage Gaps by Area

' + - '

How many of each area\'s nodes have an actual reported GPS position vs. how many were only placeable via a neighbor-based estimate (same technique used for View Path\'s approximate markers).

' + - gapHtml + + '

How many of each area\'s nodes have an actual reported GPS position vs. how many were only placeable via a neighbor-based estimate (same technique used for View Path\'s approximate markers). Sorted worst-coverage-first.

' + + '
' + gapsTableHtml(false) + '
' + unpositionedNote + '
'; + + var gapsExpanded = false; + var gapsContainer = document.getElementById('areasPositionGaps'); + function attachGapsToggle() { + var container = document.getElementById('areasPositionGaps'); + if (!container) return; + var btn = container.querySelector('[data-areas-gaps-toggle]'); + if (btn) { + btn.addEventListener('click', function () { + gapsExpanded = !gapsExpanded; + container.innerHTML = gapsTableHtml(gapsExpanded); + attachGapsToggle(); + }); + } + } + if (gapsContainer) attachGapsToggle(); } catch (e) { el.innerHTML = '
Failed to load area analytics: ' + esc(String(e)) + '
'; } diff --git a/test-analytics-areas-tab.js b/test-analytics-areas-tab.js index b8599797..4707564f 100644 --- a/test-analytics-areas-tab.js +++ b/test-analytics-areas-tab.js @@ -164,6 +164,46 @@ function makeApiStub(resp) { assert.ok(el.innerHTML.includes('20.0%'), 'ODE row should show 20.0% estimated'); }); + await testAsync('Position-Fix Coverage Gaps sorts worst-coverage-first, not the API\'s realFix-desc order', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse({ + positionGaps: [ + { areaKey: 'FULL', label: 'FullyMapped', realFix: 100, approximated: 0 }, // 0% estimated, biggest realFix + { areaKey: 'WORST', label: 'WorstCoverage', realFix: 2, approximated: 8 }, // 80% estimated, smallest realFix + { areaKey: 'MID', label: 'MidCoverage', realFix: 10, approximated: 5 }, // ~33% estimated + ], + }))); + const el = fakeEl(); + await ctx.window._analyticsRenderAreasTab(el); + const startIdx = el.innerHTML.indexOf('id="areasPositionGaps"'); + const section = el.innerHTML.slice(startIdx); + const idxWorst = section.indexOf('WorstCoverage'); + const idxMid = section.indexOf('MidCoverage'); + const idxFull = section.indexOf('FullyMapped'); + assert.ok(idxWorst > -1 && idxMid > -1 && idxFull > -1, 'all three areas should render'); + assert.ok(idxWorst < idxMid && idxMid < idxFull, 'rows should be ordered worst-% -> best-%, not by realFix'); + }); + + await testAsync('Position-Fix Coverage Gaps collapses to top 10 with a "Show all" toggle', async () => { + const manyGaps = []; + for (let i = 0; i < 15; i++) manyGaps.push({ areaKey: 'A' + i, label: 'Area' + i, realFix: 10, approximated: i }); + const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse({ positionGaps: manyGaps }))); + const el = fakeEl(); + await ctx.window._analyticsRenderAreasTab(el); + const startIdx = el.innerHTML.indexOf('id="areasPositionGaps"'); + const section = el.innerHTML.slice(startIdx, el.innerHTML.indexOf('nodes network-wide have no real GPS fix')); + const tbody = section.slice(section.indexOf(''), section.indexOf('')); + const rowCount = (tbody.match(//g) || []).length; + assert.strictEqual(rowCount, 10, 'only 10 rows should render by default'); + assert.ok(section.includes('Show all 15 areas'), 'a "Show all" toggle should appear when there are more than 10 areas'); + }); + + await testAsync('no "Show all" toggle on the Position-Fix Coverage Gaps table when there are 10 or fewer areas', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse())); + const el = fakeEl(); + await ctx.window._analyticsRenderAreasTab(el); + assert.ok(!el.innerHTML.includes('Show all'), 'no toggle should render with only 1 area in positionGaps'); + }); + await testAsync('renders the unpositioned-nodes summary note including the no-neighbor-fix subset', async () => { const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse())); const el = fakeEl();