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 <noreply@anthropic.com>
This commit is contained in:
dborup
2026-07-28 04:48:39 +02:00
co-authored by Claude Sonnet 5
parent 1e01d04c8a
commit b096f73662
2 changed files with 93 additions and 15 deletions
+53 -15
View File
@@ -6586,20 +6586,42 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
'</tr></thead><tbody>' + bridgeRows + '</tbody></table>' :
'<p class="text-muted" style="font-size:0.85em">No packet-derived neighbor edges cross between two different areas yet.</p>';
var gapRows = positionGaps.map(function (g) {
var total = g.realFix + g.approximated;
return '<tr>' +
'<td>' + esc(g.label) + '</td>' +
'<td style="text-align:right">' + g.realFix.toLocaleString() + '</td>' +
'<td style="text-align:right">' + g.approximated.toLocaleString() + '</td>' +
'<td style="text-align:right">' + pct(g.approximated, total) + '</td>' +
'</tr>';
}).join('');
var gapHtml = positionGaps.length ?
'<table class="analytics-table"><thead><tr>' +
// 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 '<div style="margin-top:6px;text-align:right"><button type="button" data-areas-gaps-toggle class="btn-link" style="font-size:12px;cursor:pointer;background:none;border:none;color:var(--link-color);padding:0">' + label + '</button></div>';
}
function gapsTableHtml(expanded) {
if (!sortedGaps.length) {
return '<p class="text-muted" style="font-size:0.85em">No unpositioned node could be placed in any area, even approximately.</p>';
}
var shown = expanded ? sortedGaps : sortedGaps.slice(0, AREAS_TOP_N);
var rows = shown.map(function (g) {
var total = g.realFix + g.approximated;
return '<tr>' +
'<td>' + esc(g.label) + '</td>' +
'<td style="text-align:right">' + g.realFix.toLocaleString() + '</td>' +
'<td style="text-align:right">' + g.approximated.toLocaleString() + '</td>' +
'<td style="text-align:right">' + pct(g.approximated, total) + '</td>' +
'</tr>';
}).join('');
return '<table class="analytics-table"><thead><tr>' +
'<th scope="col">Area</th><th scope="col">Real GPS Fix</th><th scope="col">Estimated (via Neighbors)</th><th scope="col">% Estimated</th>' +
'</tr></thead><tbody>' + gapRows + '</tbody></table>' :
'<p class="text-muted" style="font-size:0.85em">No unpositioned node could be placed in any area, even approximately.</p>';
'</tr></thead><tbody>' + rows + '</tbody></table>' + gapsToggleHtml(expanded);
}
var unpositionedNote = '<p class="text-muted" style="margin:8px 0 0;font-size:0.85em">' +
(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
'</div>' +
'<div class="analytics-card" style="margin-top:16px">' +
'<h3>Position-Fix Coverage Gaps by Area</h3>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">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).</p>' +
gapHtml +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">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.</p>' +
'<div id="areasPositionGaps">' + gapsTableHtml(false) + '</div>' +
unpositionedNote +
'</div>';
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 = '<div class="text-center" style="color:var(--status-red);padding:20px">Failed to load area analytics: ' + esc(String(e)) + '</div>';
}
+40
View File
@@ -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('<tbody>'), section.indexOf('</tbody>'));
const rowCount = (tbody.match(/<tr>/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();