/** * WiFi Channel Utilization Chart Component * * Displays channel utilization as a bar chart with recommendations. * Shows AP count, client count, and utilization score per channel. */ const ChannelChart = (function() { 'use strict'; // ========================================================================== // Configuration // ========================================================================== const CONFIG = { height: 120, barWidth: 14, barSpacing: 2, padding: { top: 15, right: 10, bottom: 25, left: 30 }, colors: { low: '#22c55e', // Green - low utilization medium: '#eab308', // Yellow - medium high: '#ef4444', // Red - high recommended: '#3b82f6', // Blue - recommended }, thresholds: { low: 0.3, medium: 0.6, }, }; // 2.4 GHz non-overlapping channels const CHANNELS_2_4 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; const NON_OVERLAPPING_2_4 = [1, 6, 11]; // 5 GHz channels (non-DFS) const CHANNELS_5 = [36, 40, 44, 48, 149, 153, 157, 161, 165]; // ========================================================================== // State // ========================================================================== let container = null; let currentBand = '2.4'; let channelStats = []; let recommendations = []; // ========================================================================== // Initialization // ========================================================================== function init(containerId, options = {}) { container = document.getElementById(containerId); if (!container) { console.warn('[ChannelChart] Container not found:', containerId); return; } Object.assign(CONFIG, options); render(); } // ========================================================================== // Update // ========================================================================== function update(stats, recs) { channelStats = stats || []; recommendations = recs || []; render(); } function setBand(band) { currentBand = band; render(); } // ========================================================================== // Rendering // ========================================================================== function render() { if (!container) return; const channels = currentBand === '2.4' ? CHANNELS_2_4 : CHANNELS_5; const nonOverlapping = currentBand === '2.4' ? NON_OVERLAPPING_2_4 : CHANNELS_5; // Build stats map const statsMap = {}; channelStats.forEach(s => { statsMap[s.channel] = s; }); // Build recommendations map const recsMap = {}; recommendations.forEach((r, i) => { recsMap[r.channel] = { rank: i + 1, ...r }; }); // Calculate dimensions const width = channels.length * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.padding.left + CONFIG.padding.right; const height = CONFIG.height + CONFIG.padding.top + CONFIG.padding.bottom; const chartHeight = CONFIG.height; // Find max values for scaling let maxApCount = 1; channelStats.forEach(s => { if (s.ap_count > maxApCount) maxApCount = s.ap_count; }); // Build SVG with viewBox for responsive scaling let svg = ` APs ${renderYAxis(chartHeight, maxApCount)} ${channels.map((ch, i) => { const stats = statsMap[ch] || { ap_count: 0, utilization_score: 0 }; const rec = recsMap[ch]; const isNonOverlapping = nonOverlapping.includes(ch); return renderBar(i, ch, stats, rec, isNonOverlapping, chartHeight, maxApCount); }).join('')} ${channels.map((ch, i) => { const x = i * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.barWidth / 2; const isNonOverlapping = nonOverlapping.includes(ch); return `${ch}`; }).join('')} `; // Add legend svg += renderLegend(); // Add recommendations if (recommendations.length > 0) { svg += renderRecommendations(); } container.innerHTML = svg; } function renderYAxis(chartHeight, maxApCount) { const ticks = []; const tickCount = Math.min(5, maxApCount); const step = Math.ceil(maxApCount / tickCount); for (let i = 0; i <= maxApCount; i += step) { const y = CONFIG.padding.top + chartHeight - (i / maxApCount * chartHeight); ticks.push(` ${i} `); } return ticks.join(''); } function renderBar(index, channel, stats, rec, isNonOverlapping, chartHeight, maxApCount) { const x = index * (CONFIG.barWidth + CONFIG.barSpacing); const barHeight = (stats.ap_count / maxApCount) * chartHeight; const y = chartHeight - barHeight; // Determine color based on utilization let gradient = 'utilGradientLow'; if (stats.utilization_score >= CONFIG.thresholds.medium) { gradient = 'utilGradientHigh'; } else if (stats.utilization_score >= CONFIG.thresholds.low) { gradient = 'utilGradientMed'; } // Recommended channel indicator const isRecommended = rec && rec.rank <= 3; const recIndicator = isRecommended ? ` ${rec.rank}` : ''; // Non-overlapping channel marker const channelMarker = isNonOverlapping ? `` : ''; return ` ${stats.ap_count > 0 ? ` ${stats.ap_count} ` : ''} ${channelMarker} ${recIndicator} `; } function renderLegend() { return `
Low
Medium
High
Non-overlapping
`; } function renderRecommendations() { const topRecs = recommendations.slice(0, 3); if (topRecs.length === 0) return ''; return `
Recommended Channels:
${topRecs.map((rec, i) => `
#${i + 1} Ch ${rec.channel} (${rec.band}) ${rec.is_dfs ? 'DFS' : ''}
`).join('')}
`; } // ========================================================================== // Public API // ========================================================================== return { init, update, setBand, }; })();