()
| 2190 | </div></div>`).join('') || '<div class="py-4 text-center text-gray-500">No networks.</div>'; |
| 2191 | } |
| 2192 | |
| 2193 | function wifiExportCsv() { |
| 2194 | const d = _wifiState.data; if (!d) return; |
| 2195 | const cols = ['ssid', 'bssid', 'vendor', 'band', 'channel', 'width', 'standard', 'nss', 'max_phy_mbps', 'signal', 'snr', 'security', 'pmf', 'enterprise', 'wps', 'channel_util', 'tx_power_dbm', 'country']; |
| 2196 | const rows = [cols.join(',')].concat(d.aps.map(a => cols.map(c => { |
| 2197 | let v = a[c]; if (v == null) v = ''; v = String(v).replace(/"/g, '""'); |
| 2198 | return /[",\n]/.test(v) ? `"${v}"` : v; |
| 2199 | }).join(','))); |
| 2200 | const blob = new Blob([rows.join('\n')], { type: 'text/csv' }); |
| 2201 | const url = URL.createObjectURL(blob); |
| 2202 | const a = document.createElement('a'); |
| 2203 | a.href = url; a.download = `wifi-survey-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.csv`; |
| 2204 | a.click(); URL.revokeObjectURL(url); |
| 2205 | } |
| 2206 | |
| 2207 | function _esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } |
| 2208 | |
| 2209 | function wifiExportReport() { |
| 2210 | const d = _wifiState.data; |
| 2211 | // Coverage stats from the current heatmap metric. |
| 2212 | const samples = (_wifiHm.data && _wifiHm.data.samples) || []; |
| 2213 | const vals = samples.map(_wifiHmValue).filter(v => v != null); |
| 2214 | const mName = (_WIFI_HM_METRICS[_wifiHm.metric] || {}).unit || ''; |
| 2215 | let cov = ''; |
| 2216 | if (vals.length) { |
| 2217 | const mn = Math.min(...vals), mx = Math.max(...vals), av = vals.reduce((a, b) => a + b, 0) / vals.length; |
| 2218 | cov = `<p><b>${samples.length}</b> survey points · ${_wifiHm.metric.toUpperCase()} min <b>${mn.toFixed(1)}</b> / avg <b>${av.toFixed(1)}</b> / max <b>${mx.toFixed(1)}</b> ${mName}</p>`; |
| 2219 | } |
| 2220 | // Design / build plan summary (walls, columns, planned mesh nodes). |
| 2221 | let plan = ''; |
| 2222 | const pWalls = _wifiHm.walls || [], pCols = _wifiHm.columns || [], pAps = _wifiHm.predictAps || []; |
| 2223 | if (pWalls.length || pCols.length || pAps.length) { |
| 2224 | const tally = (arr) => { |
| 2225 | const m = {}; arr.forEach(o => { const k = o.material || 'wall'; m[k] = (m[k] || 0) + 1; }); |
| 2226 | return Object.keys(m).map(k => `${m[k]}× ${_esc(k)}`).join(', '); |
| 2227 | }; |
| 2228 | plan = '<ul>'; |
| 2229 | if (pAps.length) plan += `<li><b>${pAps.length}</b> planned AP node${pAps.length > 1 ? 's' : ''} (predictive mesh)</li>`; |
| 2230 | if (pWalls.length) plan += `<li><b>${pWalls.length}</b> wall${pWalls.length > 1 ? 's' : ''}: ${tally(pWalls)}</li>`; |
| 2231 | if (pCols.length) plan += `<li><b>${pCols.length}</b> column${pCols.length > 1 ? 's' : ''}: ${tally(pCols)}</li>`; |
| 2232 | plan += '</ul>'; |
| 2233 | } |
| 2234 | // Heatmap image, if drawn. |
| 2235 | let img = ''; |
| 2236 | try { |
| 2237 | const cv = document.getElementById('wifi-heatmap'); |
| 2238 | if (cv && (samples.length || _wifiHm.floorplan)) img = `<img src="${cv.toDataURL('image/png')}" style="max-width:100%;border:1px solid #ccc;border-radius:6px">`; |
| 2239 | } catch (e) { /* tainted canvas (cross-origin floorplan) — skip image */ } |
| 2240 | // Band summary. |
| 2241 | let bands = ''; |
| 2242 | if (d && d.spectrum) { |
| 2243 | bands = '<ul>' + Object.keys(d.spectrum).sort().map(b => { |
| 2244 | const s = d.spectrum[b]; |
| 2245 | return `<li><b>${b} GHz</b>: ${s.ap_count} APs · ${_esc(s.rating)} · best ch ${(s.recommend || []).join(', ')}</li>`; |
| 2246 | }).join('') + '</ul>'; |
| 2247 | } |
| 2248 | // Interference. |
| 2249 | let intf = ''; |
nothing calls this directly
no test coverage detected