From 4491167a603ea6e5ea363b1c1652ddcd9526528f Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 28 Jul 2026 04:53:29 +0200 Subject: [PATCH] feat: extend collapse-to-top-10 pattern to Density and Bridge Nodes sections in Areas tab dborup asked to apply the same treatment given to Position-Fix Coverage Gaps to the other two Areas sections. Node Density & Health now sorts worst-health-first (% degraded+silent descending) instead of the API's raw Total-desc order, so small struggling areas surface instead of being buried under Europa/Danmark. Cross-Area Bridge Nodes keeps its existing otherAreaCount-desc order (already most-interesting-first) and just gets the same collapse-to-10 treatment. Factored the three sections' collapsible-table rendering/wiring into shared helpers. Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 150 ++++++++++++++++++++++-------------- test-analytics-areas-tab.js | 50 ++++++++++++ 2 files changed, 141 insertions(+), 59 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index e0f384a0..5bf20317 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -6552,7 +6552,59 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf return (n / total * 100).toFixed(1) + '%'; } - var densityRows = density.map(function (a) { + // Every section below collapses to the top 10 with a "Show all N" + // toggle, same pattern as the Wardriving tab's Top Senders/Coverage + // by Observer sections — dborup flagged that a flat 36-row dump + // "fylder hele billedet" (fills the whole picture) and asked for + // this treatment across all three Areas sections, not just + // Position-Fix Coverage Gaps where it started. + var AREAS_TOP_N = 10; + + // Generic collapsible-table renderer + click wiring shared by all + // three sections below. `items` is assumed already sorted into the + // order that matters for that section (worst-first for Density and + // Position Gaps, most-cross-area-reach-first for Bridge Nodes, + // both already established before this call). + function collapsibleTableHtml(items, theadHtml, rowFn, toggleAttr, noun, emptyMsg, expanded) { + if (!items.length) return emptyMsg; + var shown = expanded ? items : items.slice(0, AREAS_TOP_N); + var rows = shown.map(rowFn).join(''); + var toggle = items.length > AREAS_TOP_N + ? '
' + : ''; + return '' + theadHtml + '' + rows + '
' + toggle; + } + function wireCollapseToggle(containerId, toggleAttr, renderFn) { + var expanded = false; + function attach() { + var container = document.getElementById(containerId); + if (!container) return; + var btn = container.querySelector('[' + toggleAttr + ']'); + if (btn) { + btn.addEventListener('click', function () { + expanded = !expanded; + container.innerHTML = renderFn(expanded); + attach(); + }); + } + } + attach(); + } + + // Density: sorted by % unhealthy (degraded+silent) descending, not + // the API's Total-desc order — a handful of big, mostly-healthy + // areas (Europa, Danmark) otherwise buried the small areas actually + // worth a look. Ties (including the common all-active 0% case) + // fall back to Total desc so the biggest areas still anchor each + // health tier. + var sortedDensity = density.slice().sort(function (a, b) { + var pctA = a.total ? (a.degraded + a.silent) / a.total : 0; + var pctB = b.total ? (b.degraded + b.silent) / b.total : 0; + if (pctB !== pctA) return pctB - pctA; + return b.total - a.total; + }); + function densityRowHtml(a) { var roleParts = Object.keys(a.roleCounts || {}).sort().map(function (r) { return r + ': ' + a.roleCounts[r]; }).join(', '); @@ -6564,14 +6616,17 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '' + a.silent.toLocaleString() + '' + '' + esc(roleParts) + '' + ''; - }).join(''); - var densityHtml = density.length ? - '' + - '' + - '' + densityRows + '
AreaTotalActiveDegradedSilentRole Mix
' : - '

No positioned nodes fall inside any configured area.

'; + } + var densityThead = 'AreaTotalActiveDegradedSilentRole Mix'; + function densityTableHtml(expanded) { + return collapsibleTableHtml(sortedDensity, densityThead, densityRowHtml, 'data-areas-density-toggle', 'areas', + '

No positioned nodes fall inside any configured area.

', expanded); + } - var bridgeRows = bridgeNodes.map(function (b) { + // Bridge nodes: already ranked most-cross-area-reach-first by the + // API (computeAreaBridgeNodes, and capped at 25) -- no re-sort + // needed, just the same collapse-to-10 treatment. + function bridgeRowHtml(b) { return '' + '' + esc(b.name) + '' + '' + esc(b.label) + '' + @@ -6579,49 +6634,38 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '' + b.otherAreaCount.toLocaleString() + '' + '' + (b.otherAreas || []).map(esc).join(', ') + '' + ''; - }).join(''); - var bridgeHtml = bridgeNodes.length ? - '' + - '' + - '' + bridgeRows + '
NodeHome AreaCross-Area EdgesOther Areas ReachedWhich Areas
' : - '

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

'; + } + var bridgeThead = 'NodeHome AreaCross-Area EdgesOther Areas ReachedWhich Areas'; + function bridgeTableHtml(expanded) { + return collapsibleTableHtml(bridgeNodes, bridgeThead, bridgeRowHtml, 'data-areas-bridge-toggle', 'nodes', + '

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

', expanded); + } - // 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; + // Position gaps: 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. 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 gapsRowHtml(g) { + var total = g.realFix + g.approximated; + return '' + + '' + esc(g.label) + '' + + '' + g.realFix.toLocaleString() + '' + + '' + g.approximated.toLocaleString() + '' + + '' + pct(g.approximated, total) + '' + + ''; } + var gapsThead = 'AreaReal GPS FixEstimated (via Neighbors)% Estimated'; 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 '' + - '' + - '' + rows + '
AreaReal GPS FixEstimated (via Neighbors)% Estimated
' + gapsToggleHtml(expanded); + return collapsibleTableHtml(sortedGaps, gapsThead, gapsRowHtml, 'data-areas-gaps-toggle', 'areas', + '

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

', 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.' : '.') + @@ -6630,13 +6674,13 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf el.innerHTML = '

' + '

Node Density & Health by Area

' + - '

Nodes with a real GPS fix inside each configured area — a node in a narrower sub-area also counts toward any broader area containing it (e.g. a city area rolls up into its region).

' + - densityHtml + + '

Nodes with a real GPS fix inside each configured area — a node in a narrower sub-area also counts toward any broader area containing it (e.g. a city area rolls up into its region). Sorted worst-health-first.

' + + '
' + densityTableHtml(false) + '
' + '
' + '
' + '

Cross-Area Bridge Nodes

' + '

Nodes whose packet-derived neighbor connections reach into at least one OTHER area than their own, ranked by how many other areas they reach. Distinct from the network-wide Bridge Score elsewhere in this app, which has no concept of areas at all.

' + - bridgeHtml + + '
' + bridgeTableHtml(false) + '
' + '
' + '
' + '

Position-Fix Coverage Gaps by Area

' + @@ -6645,21 +6689,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf 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(); + wireCollapseToggle('areasDensity', 'data-areas-density-toggle', densityTableHtml); + wireCollapseToggle('areasBridgeNodes', 'data-areas-bridge-toggle', bridgeTableHtml); + wireCollapseToggle('areasPositionGaps', 'data-areas-gaps-toggle', gapsTableHtml); } 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 4707564f..843b17fd 100644 --- a/test-analytics-areas-tab.js +++ b/test-analytics-areas-tab.js @@ -155,6 +155,56 @@ function makeApiStub(resp) { assert.ok(el.innerHTML.includes('Danmark (alle), Jylland'), 'other areas reached should be listed'); }); + await testAsync('Node Density & Health sorts worst-health-first, not the API\'s Total-desc order', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse({ + density: [ + { areaKey: 'BIG', label: 'BigHealthy', total: 100, active: 100, degraded: 0, silent: 0, roleCounts: {} }, // 0% unhealthy, biggest + { areaKey: 'SMALL', label: 'SmallSick', total: 4, active: 1, degraded: 1, silent: 2, roleCounts: {} }, // 75% unhealthy, smallest + { areaKey: 'MID', label: 'MidSick', total: 10, active: 5, degraded: 3, silent: 2, roleCounts: {} }, // 50% unhealthy + ], + }))); + const el = fakeEl(); + await ctx.window._analyticsRenderAreasTab(el); + const startIdx = el.innerHTML.indexOf('id="areasDensity"'); + const section = el.innerHTML.slice(startIdx); + const idxSmall = section.indexOf('SmallSick'); + const idxMid = section.indexOf('MidSick'); + const idxBig = section.indexOf('BigHealthy'); + assert.ok(idxSmall > -1 && idxMid > -1 && idxBig > -1, 'all three areas should render'); + assert.ok(idxSmall < idxMid && idxMid < idxBig, 'rows should be ordered worst-health -> best-health, not by Total'); + }); + + await testAsync('Node Density & Health collapses to top 10 with a "Show all" toggle', async () => { + const manyAreas = []; + for (let i = 0; i < 15; i++) manyAreas.push({ areaKey: 'A' + i, label: 'Area' + i, total: 10, active: 10 - i, degraded: 0, silent: i, roleCounts: {} }); + const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse({ density: manyAreas }))); + const el = fakeEl(); + await ctx.window._analyticsRenderAreasTab(el); + const startIdx = el.innerHTML.indexOf('id="areasDensity"'); + const section = el.innerHTML.slice(startIdx, el.innerHTML.indexOf('id="areasBridgeNodes"')); + 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('Cross-Area Bridge Nodes keeps the API\'s otherAreaCount-desc order and collapses to top 10', async () => { + const manyBridges = []; + for (let i = 0; i < 12; i++) manyBridges.push({ publicKey: 'pk' + i, name: 'Bridge' + i, areaKey: 'A', label: 'AreaA', edgeCount: 5, otherAreaCount: 12 - i, otherAreas: ['AreaB'] }); + const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse({ bridgeNodes: manyBridges }))); + const el = fakeEl(); + await ctx.window._analyticsRenderAreasTab(el); + const startIdx = el.innerHTML.indexOf('id="areasBridgeNodes"'); + const section = el.innerHTML.slice(startIdx, el.innerHTML.indexOf('id="areasPositionGaps"')); + const idxFirst = section.indexOf('Bridge0'); + const idxLast = section.indexOf('Bridge9'); + assert.ok(idxFirst > -1 && idxFirst < idxLast, 'highest otherAreaCount (Bridge0) should render before a lower one (Bridge9)'); + 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 12 nodes'), 'a "Show all" toggle should appear when there are more than 10 bridge nodes'); + }); + await testAsync('renders the Position-Fix Coverage Gaps table with a computed percentage', async () => { const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse())); const el = fakeEl();