feat(wifi): channel heatmap and security ring chart

Replace static channel bar chart and security dots with a scrolling
2.4 GHz channel heatmap (up to 10 scan snapshots) and an SVG donut
security ring showing WPA2/WPA3/WEP/Open network distribution.
This commit is contained in:
James Smith
2026-03-26 22:38:31 +00:00
parent 2fce80677a
commit e5a0635418
3 changed files with 274 additions and 173 deletions

View File

@@ -3866,100 +3866,158 @@ header h1 .tagline {
to { transform: rotate(360deg); }
}
/* WiFi Analysis Panel (RIGHT) */
/* WiFi Analysis Panel */
.wifi-analysis-panel {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
overflow: hidden;
}
.wifi-channel-section,
.wifi-security-section {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 12px;
overflow: hidden;
}
.wifi-channel-section {
flex: 1;
}
.wifi-channel-section h5,
.wifi-security-section h5 {
margin: 0 0 10px 0;
color: var(--accent-cyan);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wifi-channel-tabs {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.channel-band-tab {
flex: 1;
padding: 6px 10px;
font-size: 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
transition: all 0.2s;
}
.channel-band-tab:hover {
background: var(--bg-secondary);
}
.channel-band-tab.active {
background: var(--accent-cyan);
color: var(--text-inverse);
border-color: var(--accent-cyan);
}
.wifi-channel-chart {
min-height: 120px;
overflow-x: auto;
overflow-y: hidden;
}
.wifi-security-stats {
display: flex;
flex-direction: column;
gap: 6px;
}
.wifi-security-item {
.wifi-analysis-panel-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
justify-content: space-between;
padding: 10px 12px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.wifi-security-dot {
width: 10px;
height: 10px;
.wifi-analysis-panel-header .panel-title {
color: var(--accent-cyan);
font-size: 10px;
letter-spacing: 1.5px;
text-transform: uppercase;
}
.wifi-detail-back-btn {
font-family: inherit;
font-size: 9px;
color: var(--text-dim);
background: none;
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 2px 8px;
cursor: pointer;
transition: color 0.15s;
}
.wifi-detail-back-btn:hover { color: var(--text-primary); }
/* Heatmap */
.wifi-heatmap-wrap {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
overflow: hidden;
}
.wifi-heatmap-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.wifi-heatmap-ch-labels {
display: grid;
grid-template-columns: 26px repeat(11, 1fr);
gap: 2px;
}
.wifi-heatmap-ch-label {
text-align: center;
font-size: 8px;
color: var(--text-dim);
}
.wifi-heatmap-grid {
display: grid;
grid-template-columns: 26px repeat(11, 1fr);
gap: 2px;
flex: 1;
min-height: 0;
}
.wifi-heatmap-time-label {
font-size: 8px;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 4px;
}
.wifi-heatmap-cell {
border-radius: 2px;
min-height: 10px;
}
.wifi-security-item.wpa3 .wifi-security-dot { background: var(--accent-green); }
.wifi-security-item.wpa2 .wifi-security-dot { background: var(--accent-cyan); }
.wifi-security-item.wep .wifi-security-dot { background: var(--accent-orange); }
.wifi-security-item.open .wifi-security-dot { background: var(--accent-red); }
.wifi-security-count {
margin-left: auto;
font-weight: 600;
color: var(--text-primary);
.wifi-heatmap-empty {
grid-column: 1 / -1;
padding: 16px;
text-align: center;
color: var(--text-dim);
font-size: 10px;
}
.wifi-heatmap-legend {
display: flex;
align-items: center;
gap: 6px;
font-size: 9px;
color: var(--text-dim);
margin-top: 2px;
}
.wifi-heatmap-legend-grad {
flex: 1;
height: 6px;
border-radius: 3px;
background: linear-gradient(90deg, #0d1117 0%, #0d4a6e 30%, #0ea5e9 60%, #f97316 80%, #ef4444 100%);
}
/* Security ring */
.wifi-security-ring-wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
.wifi-security-ring-legend {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.wifi-security-ring-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
}
.wifi-security-ring-dot {
width: 7px;
height: 7px;
border-radius: 1px;
flex-shrink: 0;
}
.wifi-security-ring-name { color: var(--text-dim); flex: 1; }
.wifi-security-ring-count { color: var(--text-primary); font-weight: 600; }
/* WiFi Detail Drawer */
.wifi-detail-drawer {
display: none;

View File

@@ -119,6 +119,7 @@ const WiFiMode = (function() {
let probeRequests = [];
let channelStats = [];
let recommendations = [];
let channelHistory = []; // max 10 entries, each { timestamp, channels: {1:N,...,11:N} }
// UI state
let selectedBssid = null;
@@ -167,7 +168,7 @@ const WiFiMode = (function() {
initScanModeTabs();
initNetworkFilters();
initSortControls();
initChannelChart();
initHeatmap();
scheduleRender({ table: true, stats: true, radar: true, chart: true });
// Check if already scanning
@@ -199,9 +200,16 @@ const WiFiMode = (function() {
networkList: document.getElementById('wifiNetworkList'),
networkFilters: document.getElementById('wifiNetworkFilters'),
// Visualizations
channelChart: document.getElementById('wifiChannelChart'),
channelBandTabs: document.getElementById('wifiChannelBandTabs'),
// Visualizations — heatmap & security ring
heatmapGrid: document.getElementById('wifiHeatmapGrid'),
heatmapChLabels: document.getElementById('wifiHeatmapChLabels'),
heatmapCount: document.getElementById('wifiHeatmapCount'),
securityRingSvg: document.getElementById('wifiSecurityRingSvg'),
securityRingLegend: document.getElementById('wifiSecurityRingLegend'),
heatmapView: document.getElementById('wifiHeatmapView'),
detailView: document.getElementById('wifiDetailView'),
rightPanelTitle: document.getElementById('wifiRightPanelTitle'),
detailBackBtn: document.getElementById('wifiDetailBackBtn'),
// Zone summary
zoneImmediate: document.getElementById('wifiZoneImmediate'),
@@ -1076,7 +1084,6 @@ const WiFiMode = (function() {
if (pendingRender.table) renderNetworks();
if (pendingRender.stats) updateStats();
if (pendingRender.radar) renderRadar(Array.from(networks.values()));
if (pendingRender.chart) updateChannelChart();
if (pendingRender.detail && selectedBssid) {
updateDetailPanel(selectedBssid, { refreshClients: false });
}
@@ -1092,6 +1099,18 @@ const WiFiMode = (function() {
function renderNetworks() {
if (!elements.networkList) return;
// Snapshot 2.4 GHz channel utilisation (use all networks, not filtered)
const snapshot = { timestamp: Date.now(), channels: {} };
for (let ch = 1; ch <= 11; ch++) snapshot.channels[ch] = 0;
Array.from(networks.values())
.filter(n => n.band && n.band.startsWith('2.4'))
.forEach(n => {
const ch = parseInt(n.channel);
if (ch >= 1 && ch <= 11) snapshot.channels[ch]++;
});
channelHistory.unshift(snapshot);
if (channelHistory.length > 10) channelHistory.pop();
// Filter networks
let filtered = Array.from(networks.values());
@@ -1162,6 +1181,9 @@ const WiFiMode = (function() {
const sel = elements.networkList.querySelector(`[data-bssid="${CSS.escape(selectedBssid)}"]`);
if (sel) sel.classList.add('selected');
}
renderHeatmap();
renderSecurityRing(Array.from(networks.values()));
}
function createNetworkRow(network) {
@@ -1549,81 +1571,101 @@ const WiFiMode = (function() {
// Channel Chart
// ==========================================================================
function initChannelChart() {
if (!elements.channelChart) return;
function initHeatmap() {
if (!elements.heatmapChLabels) return;
// Time-label placeholder + 11 channel labels
elements.heatmapChLabels.innerHTML =
'<div class="wifi-heatmap-ch-label"></div>' +
[1,2,3,4,5,6,7,8,9,10,11].map(ch =>
`<div class="wifi-heatmap-ch-label">${ch}</div>`
).join('');
}
// Initialize channel chart component
if (typeof ChannelChart !== 'undefined') {
ChannelChart.init('wifiChannelChart');
function renderHeatmap() {
if (!elements.heatmapGrid) return;
if (channelHistory.length === 0) {
elements.heatmapGrid.innerHTML =
'<div class="wifi-heatmap-empty">Scan to populate channel history</div>';
if (elements.heatmapCount) elements.heatmapCount.textContent = '0';
return;
}
// Band tabs
if (elements.channelBandTabs) {
elements.channelBandTabs.addEventListener('click', (e) => {
if (e.target.matches('.channel-band-tab')) {
const band = e.target.dataset.band;
elements.channelBandTabs.querySelectorAll('.channel-band-tab').forEach(t => {
t.classList.toggle('active', t.dataset.band === band);
});
updateChannelChart(band);
}
if (elements.heatmapCount) elements.heatmapCount.textContent = channelHistory.length;
// Find max value for colour scale
let maxVal = 1;
channelHistory.forEach(snap => {
Object.values(snap.channels).forEach(v => { if (v > maxVal) maxVal = v; });
});
const rows = channelHistory.map((snap, i) => {
const timeLabel = i === 0 ? 'now' : '';
const cells = [1,2,3,4,5,6,7,8,9,10,11].map(ch => {
const v = snap.channels[ch] || 0;
return `<div class="wifi-heatmap-cell" style="background:${congestionColor(v, maxVal)}"></div>`;
});
}
return `<div class="wifi-heatmap-time-label">${timeLabel}</div>${cells.join('')}`;
});
elements.heatmapGrid.innerHTML = rows.join('');
}
function calculateChannelStats() {
// Calculate channel stats from current networks
const stats = {};
const networksList = Array.from(networks.values());
// Initialize all channels
// 2.4 GHz: channels 1-13
for (let ch = 1; ch <= 13; ch++) {
stats[ch] = { channel: ch, band: '2.4GHz', ap_count: 0, client_count: 0, utilization_score: 0 };
}
// 5 GHz: common channels
[36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165].forEach(ch => {
stats[ch] = { channel: ch, band: '5GHz', ap_count: 0, client_count: 0, utilization_score: 0 };
});
// Count APs per channel
networksList.forEach(net => {
const ch = parseInt(net.channel);
if (stats[ch]) {
stats[ch].ap_count++;
stats[ch].client_count += (net.client_count || 0);
}
});
// Calculate utilization score (0-1)
const maxAPs = Math.max(1, ...Object.values(stats).map(s => s.ap_count));
Object.values(stats).forEach(s => {
s.utilization_score = s.ap_count / maxAPs;
});
return Object.values(stats).filter(s => s.ap_count > 0 || [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165].includes(s.channel));
function congestionColor(value, maxValue) {
if (value === 0 || maxValue === 0) return '#0d1117';
const ratio = value / maxValue;
if (ratio < 0.05) return '#0d1117';
if (ratio < 0.25) return `rgba(13,74,110,${(ratio * 4).toFixed(2)})`;
if (ratio < 0.5) return `rgba(14,165,233,${ratio.toFixed(2)})`;
if (ratio < 0.75) return `rgba(249,115,22,${ratio.toFixed(2)})`;
return `rgba(239,68,68,${ratio.toFixed(2)})`;
}
function updateChannelChart(band) {
if (typeof ChannelChart === 'undefined') return;
function renderSecurityRing(networksList) {
const svg = elements.securityRingSvg;
const legend = elements.securityRingLegend;
if (!svg || !legend) return;
// Use the currently active band tab if no band specified
if (!band) {
const activeTab = elements.channelBandTabs && elements.channelBandTabs.querySelector('.channel-band-tab.active');
band = activeTab ? activeTab.dataset.band : '2.4';
}
const C = 2 * Math.PI * 15; // circumference ≈ 94.25
const sec = networksList.reduce((acc, n) => {
const s = (n.security || '').toLowerCase();
if (s.includes('wpa3')) acc.wpa3++;
else if (s.includes('wpa')) acc.wpa2++;
else if (s.includes('wep')) acc.wep++;
else acc.open++;
return acc;
}, { wpa2: 0, open: 0, wpa3: 0, wep: 0 });
// Recalculate channel stats from networks if needed
if (channelStats.length === 0 && networks.size > 0) {
channelStats = calculateChannelStats();
}
const total = networksList.length || 1;
const segments = [
{ label: 'WPA2', color: '#38c180', count: sec.wpa2 },
{ label: 'Open', color: '#e25d5d', count: sec.open },
{ label: 'WPA3', color: '#4aa3ff', count: sec.wpa3 },
{ label: 'WEP', color: '#d6a85e', count: sec.wep },
];
// Filter stats by band
const bandFilter = band === '2.4' ? '2.4GHz' : band === '5' ? '5GHz' : '6GHz';
const filteredStats = channelStats.filter(s => s.band === bandFilter);
const filteredRecs = recommendations.filter(r => r.band === bandFilter);
let offset = 0;
const arcs = segments.map(seg => {
const arcLen = (seg.count / total) * C;
const arc = `<circle cx="24" cy="24" r="15" fill="none"
stroke="${seg.color}" stroke-width="7"
stroke-dasharray="${arcLen.toFixed(2)} ${(C - arcLen).toFixed(2)}"
stroke-dashoffset="${(-offset).toFixed(2)}"
transform="rotate(-90 24 24)"/>`;
offset += arcLen;
return arc;
});
ChannelChart.update(filteredStats, filteredRecs);
svg.innerHTML = arcs.join('') +
'<circle cx="24" cy="24" r="9" fill="var(--bg-primary)"/>';
legend.innerHTML = segments.map(seg => `
<div class="wifi-security-ring-item">
<div class="wifi-security-ring-dot" style="background:${seg.color}"></div>
<span class="wifi-security-ring-name">${seg.label}</span>
<span class="wifi-security-ring-count">${seg.count}</span>
</div>
`).join('');
}
// ==========================================================================

View File

@@ -936,40 +936,41 @@
</div>
</div>
<!-- RIGHT: Channel Analysis + Security -->
<!-- RIGHT: Channel Heatmap + Security Ring -->
<div class="wifi-analysis-panel">
<div class="wifi-channel-section">
<h5>Channel Analysis</h5>
<div class="wifi-channel-tabs" id="wifiChannelBandTabs">
<button class="channel-band-tab active" data-band="2.4">2.4 GHz</button>
<button class="channel-band-tab" data-band="5">5 GHz</button>
</div>
<div id="wifiChannelChart" class="wifi-channel-chart"></div>
<div class="wifi-analysis-panel-header">
<span class="panel-title" id="wifiRightPanelTitle">Channel Heatmap</span>
<button class="wifi-detail-back-btn" id="wifiDetailBackBtn"
style="display:none" onclick="WiFiMode.closeDetail()">← Back</button>
</div>
<div class="wifi-security-section">
<h5>Security Overview</h5>
<div class="wifi-security-stats">
<div class="wifi-security-item wpa3">
<span class="wifi-security-dot"></span>
<span>WPA3</span>
<span class="wifi-security-count" id="wpa3Count">0</span>
<!-- Default: heatmap + security ring -->
<div id="wifiHeatmapView" style="display:flex; flex-direction:column; flex:1; overflow:hidden;">
<div class="wifi-heatmap-wrap">
<div class="wifi-heatmap-label">
2.4 GHz · Last <span id="wifiHeatmapCount">0</span> scans
</div>
<div class="wifi-security-item wpa2">
<span class="wifi-security-dot"></span>
<span>WPA2</span>
<span class="wifi-security-count" id="wpa2Count">0</span>
<div class="wifi-heatmap-ch-labels" id="wifiHeatmapChLabels">
<!-- 11 channel labels (111), generated once by JS -->
</div>
<div class="wifi-security-item wep">
<span class="wifi-security-dot"></span>
<span>WEP</span>
<span class="wifi-security-count" id="wepCount">0</span>
</div>
<div class="wifi-security-item open">
<span class="wifi-security-dot"></span>
<span>Open</span>
<span class="wifi-security-count" id="openCount">0</span>
<div class="wifi-heatmap-grid" id="wifiHeatmapGrid"></div>
<div class="wifi-heatmap-legend">
<span>Low</span>
<div class="wifi-heatmap-legend-grad"></div>
<span>High</span>
</div>
</div>
<div class="wifi-security-ring-wrap">
<svg id="wifiSecurityRingSvg" viewBox="0 0 48 48" width="48" height="48">
<circle cx="24" cy="24" r="9" fill="var(--bg-primary)"/>
</svg>
<div class="wifi-security-ring-legend" id="wifiSecurityRingLegend"></div>
</div>
</div>
<!-- On network click: detail panel (wired in Task 5) -->
<div id="wifiDetailView" style="display:none; flex:1; overflow-y:auto;">
<!-- populated in Task 5 -->
</div>
</div>
</div>