()
| 2278 | a.click(); URL.revokeObjectURL(url); |
| 2279 | } |
| 2280 | |
| 2281 | function _esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } |
| 2282 | |
| 2283 | function wifiExportReport() { |
| 2284 | const d = _wifiState.data; |
| 2285 | // Coverage stats from the current heatmap metric. |
| 2286 | const samples = (_wifiHm.data && _wifiHm.data.samples) || []; |
| 2287 | const vals = samples.map(_wifiHmValue).filter(v => v != null); |
| 2288 | const mName = (_WIFI_HM_METRICS[_wifiHm.metric] || {}).unit || ''; |
| 2289 | let cov = ''; |
| 2290 | if (vals.length) { |
| 2291 | const mn = Math.min(...vals), mx = Math.max(...vals), av = vals.reduce((a, b) => a + b, 0) / vals.length; |
| 2292 | 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>`; |
| 2293 | } |
| 2294 | // Design / build plan summary (walls, columns, planned mesh nodes). |
| 2295 | let plan = ''; |
| 2296 | const pWalls = _wifiHm.walls || [], pCols = _wifiHm.columns || [], pAps = _wifiHm.predictAps || []; |
| 2297 | if (pWalls.length || pCols.length || pAps.length) { |
| 2298 | const tally = (arr) => { |
| 2299 | const m = {}; arr.forEach(o => { const k = o.material || 'wall'; m[k] = (m[k] || 0) + 1; }); |
| 2300 | return Object.keys(m).map(k => `${m[k]}× ${_esc(k)}`).join(', '); |
| 2301 | }; |
| 2302 | plan = '<ul>'; |
| 2303 | if (pAps.length) plan += `<li><b>${pAps.length}</b> planned AP node${pAps.length > 1 ? 's' : ''} (predictive mesh)</li>`; |
| 2304 | if (pWalls.length) plan += `<li><b>${pWalls.length}</b> wall${pWalls.length > 1 ? 's' : ''}: ${tally(pWalls)}</li>`; |
| 2305 | if (pCols.length) plan += `<li><b>${pCols.length}</b> column${pCols.length > 1 ? 's' : ''}: ${tally(pCols)}</li>`; |
| 2306 | plan += '</ul>'; |
| 2307 | } |
| 2308 | // Heatmap image, if drawn. |
| 2309 | let img = ''; |
| 2310 | try { |
| 2311 | const cv = document.getElementById('wifi-heatmap'); |
| 2312 | if (cv && (samples.length || _wifiHm.floorplan)) img = `<img src="${cv.toDataURL('image/png')}" style="max-width:100%;border:1px solid #ccc;border-radius:6px">`; |
| 2313 | } catch (e) { /* tainted canvas (cross-origin floorplan) — skip image */ } |
| 2314 | // Band summary. |
| 2315 | let bands = ''; |
| 2316 | if (d && d.spectrum) { |
| 2317 | bands = '<ul>' + Object.keys(d.spectrum).sort().map(b => { |
| 2318 | const s = d.spectrum[b]; |
| 2319 | return `<li><b>${b} GHz</b>: ${s.ap_count} APs · ${_esc(s.rating)} · best ch ${(s.recommend || []).join(', ')}</li>`; |
| 2320 | }).join('') + '</ul>'; |
| 2321 | } |
| 2322 | // Interference. |
| 2323 | let intf = ''; |
| 2324 | if (d && d.interference) { |
| 2325 | const co = d.interference.co_channel || [], ov = d.interference.adjacent_overlap || []; |
| 2326 | if (co.length) intf += '<p><b>Co-channel:</b> ' + co.slice(0, 8).map(g => `${g.band}GHz ch${g.channel} (${g.count} APs)`).join(', ') + '</p>'; |
| 2327 | if (ov.length) intf += `<p><b>2.4 GHz overlap:</b> ${ov.length} overlapping pair(s) — use ch 1/6/11.</p>`; |
| 2328 | if (!intf) intf = '<p>No significant co-/adjacent-channel interference.</p>'; |
| 2329 | } |
| 2330 | // AP inventory table. |
| 2331 | const aps = (d && d.aps) || []; |
| 2332 | const rows = aps.map(a => `<tr><td>${_esc(a.ssid) || '<i>hidden</i>'}</td><td class="mono">${_esc(a.bssid)}</td><td>${_esc(a.vendor) || '—'}</td><td>${a.band}</td><td>${a.channel}</td><td>${a.width}M</td><td>${a.signal}</td><td>${a.snr == null ? '—' : a.snr}</td><td>${_esc(a.security)}</td></tr>`).join(''); |
| 2333 | const now = new Date(); |
| 2334 | const issues = aps.filter(a => a.security_findings && a.security_findings.length); |
| 2335 | const html = `<!doctype html><html><head><meta charset="utf-8"><title>WiFi Survey Report</title> |
| 2336 | <style> |
| 2337 | body{font:13px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;color:#111;max-width:900px;margin:24px auto;padding:0 16px} |
no test coverage detected