()
| 2495 | }); |
| 2496 | // Channel ticks (channels present) |
| 2497 | ctx.fillStyle = '#64748b'; ctx.font = `${9 * K}px sans-serif`; |
| 2498 | const present = [...new Set(d.aps.filter(a => a.band === b).map(a => a.channel))].sort((x, y) => x - y); |
| 2499 | present.forEach(ch => ctx.fillText(ch + '', xFor(b, ch), H - padB + 12 * K)); |
| 2500 | if (o.big) { |
| 2501 | ctx.fillStyle = '#475569'; ctx.font = '10px sans-serif'; |
| 2502 | ctx.fillText('channel', (r.x0 + r.x1) / 2, H - padB + 28); |
| 2503 | } |
| 2504 | if (bands.length > 1) { ctx.strokeStyle = '#334155'; ctx.beginPath(); ctx.moveTo(r.x1 + gap / 2, padT); ctx.lineTo(r.x1 + gap / 2, H - padB); ctx.stroke(); } |
| 2505 | }); |
| 2506 | // Baseline |
| 2507 | ctx.strokeStyle = '#334155'; ctx.beginPath(); ctx.moveTo(padL, H - padB); ctx.lineTo(W - padR, H - padB); ctx.stroke(); |
| 2508 | // Sort so weaker drawn first (stronger on top) |
| 2509 | const aps = [...d.aps].filter(a => bands.includes(a.band)).sort((a, b) => (a.signal || -999) - (b.signal || -999)); |
| 2510 | aps.forEach(a => { |
| 2511 | const centerCh = a.center_freq ? _wifiFreqToChannel(a.center_freq) : a.channel; |
| 2512 | const half = a.width / 10; // channel-number half-width |
| 2513 | const seg = ranges[a.band]; |
| 2514 | const clamp = (x) => Math.max(seg.x0, Math.min(seg.x1, x)); |
| 2515 | const xC = clamp(xFor(a.band, centerCh)); |
| 2516 | const xL = clamp(xFor(a.band, centerCh - half)), xR = clamp(xFor(a.band, centerCh + half)); |
| 2517 | const y = yFor(a.signal); const col = _wifiSignalColor(a.signal); |
| 2518 | const sel = _wifiState.selected === a.bssid; |
| 2519 | const hov = _wifiState.hoverBssid === a.bssid; |
| 2520 | // Pixel box → BSSID, for hover/click. Recorded in draw order, so a |
| 2521 | // reverse scan finds whatever is visually on top. |
| 2522 | canvas._wifiHits.push({ bssid: a.bssid, x0: Math.min(xL, xC - 2), x1: Math.max(xR, xC + 2), y0: y - 12, y1: H - padB }); |
| 2523 | if (_wifiState.view === 'bar') { |
| 2524 | ctx.fillStyle = col + (sel ? 'ff' : hov ? 'ee' : 'cc'); |
| 2525 | ctx.fillRect(xL, y, Math.max(3, xR - xL), (H - padB) - y); |
| 2526 | ctx.strokeStyle = sel || hov ? '#fff' : col; ctx.lineWidth = sel ? 2 : hov ? 1.5 : 1; |
| 2527 | ctx.strokeRect(xL, y, Math.max(3, xR - xL), (H - padB) - y); |
| 2528 | } else { |
| 2529 | // Dome: bell curve centred on channel, spanning its width |
| 2530 | ctx.beginPath(); ctx.moveTo(xL, H - padB); |
| 2531 | const steps = 28; |
| 2532 | for (let s = 0; s <= steps; s++) { |
| 2533 | const t = s / steps; const x = xL + (xR - xL) * t; |
| 2534 | const bell = Math.cos((t - 0.5) * Math.PI); // 0..1..0 |
| 2535 | const yy = (H - padB) - bell * ((H - padB) - y); |
| 2536 | ctx.lineTo(x, yy); |
| 2537 | } |
| 2538 | ctx.lineTo(xR, H - padB); ctx.closePath(); |
| 2539 | const grad = ctx.createLinearGradient(0, y, 0, H - padB); |
| 2540 | grad.addColorStop(0, col + (sel ? 'cc' : hov ? '99' : '66')); grad.addColorStop(1, col + '08'); |
| 2541 | ctx.fillStyle = grad; ctx.fill(); |
| 2542 | ctx.strokeStyle = sel || hov ? '#fff' : col; ctx.lineWidth = sel ? 2.5 : hov ? 2 : 1.5; ctx.stroke(); |
| 2543 | } |
| 2544 | // WIDS-flagged AP: red dashed locator line + outline so it stands out |
| 2545 | // even in a crowded band (highlight persists until dismissed). |
| 2546 | if (_wifiState.flagged && _wifiState.flagged.bssid === a.bssid) { |
| 2547 | ctx.save(); |
| 2548 | ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 1; ctx.setLineDash([5, 4]); |
| 2549 | ctx.beginPath(); ctx.moveTo(xC, padT); ctx.lineTo(xC, H - padB); ctx.stroke(); |
| 2550 | ctx.lineWidth = 2; ctx.setLineDash([4, 3]); |
| 2551 | ctx.strokeRect(xL - 2, y - 2, Math.max(3, xR - xL) + 4, (H - padB) - y + 2); |
| 2552 | ctx.restore(); |
| 2553 | ctx.fillStyle = '#f87171'; ctx.font = `bold ${10 * K}px sans-serif`; ctx.textAlign = 'center'; |
| 2554 | ctx.fillText('⚠ WIDS', xC, y - 16 * K); |
no test coverage detected