' +
+ // 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 '
' +
+ '
' + esc(g.label) + '
' +
+ '
' + g.realFix.toLocaleString() + '
' +
+ '
' + g.approximated.toLocaleString() + '
' +
+ '
' + pct(g.approximated, total) + '
' +
+ '
';
+ }).join('');
+ return '
' +
'
Area
Real GPS Fix
Estimated (via Neighbors)
% Estimated
' +
- '
' + gapRows + '
' :
- '
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();