clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / frontend / dist / js / reception.js
reception.js 22.2K
1'use strict';23// Reception, a tab of Settings: what the tuners measured.45// ---------- reception ----------6// Reception is what the tuners measured, by RF channel, with what the FCC7// licenses on each; nothing in it is estimated. The rail has the tuner and8// Measure now, which has the server read every RF channel worth reading9// on a tuner nobody is using, the ones its scan found first. The table10// lists the RF channels, those found first; the radar places their11// transmitters, and the detail under it is the picked RF channel's12// readings over time and the stations on it.1314// applySignal takes in what the server measured: a snapshot's antenna15// section, or /api/signal.16function applySignal(sig) {17 const was = state.antenna && state.antenna.sweep;18 const wasScanning = !!(state.antenna && state.antenna.tuner.scanning);19 state.antenna = { tuner: sig.tuner || {}, muxes: sig.muxes || [], sweep: sig.sweep || {} };20 state.byRF = new Map(state.antenna.muxes.map((m) => [m.rf, m]));21 const by = new Map((sig.channels || []).map((c) => [c.number, c]));22 for (const ch of state.antennaChans) {23 const c = by.get(ch.number);24 if (c) [ch.signal, ch.recent] = [c.signal, c.recent];25 }26 state.sigStamp++;27 const sw = state.antenna.sweep;28 if (was && was.running && !sw.running && receptionShown()) {29 toast(sw.note ? `Measure now ${sw.note}` : `Measured ${sw.done} RF channels`, 4000);30 }31 // A channel scan just finished: its channels are the lineup now.32 if (wasScanning && !state.antenna.tuner.scanning) {33 scan(false).then(() => {34 renderAll();35 toast(`The scan found ${state.antennaChans.length} channels`, 4000);36 }).catch((e) => log('warn', e));37 }38}3940let signalAt = 0;41let signalBusy = false;4243// loadSignal fetches what was measured and redraws what shows it.44async function loadSignal() {45 if (!state.info || state.info.antenna === false || signalBusy || !api().Signal) return;46 signalBusy = true;47 try {48 applySignal(await api().Signal());49 } catch (e) {50 log('warn', `signal: ${e}`);51 return;52 } finally {53 signalBusy = false;54 signalAt = Date.now();55 }56 if (receptionShown()) renderAntenna();57 if (state.view === 'guide') renderGuide();58 if (state.view === 'tv' && $('#banner').classList.contains('show')) renderBanner();59}6061// signalTick reads the signal again while it's on screen: every 2 s while62// Measure now or a channel scan runs, every 10 s in Reception or with an63// antenna channel's banner up, every 30 s in the guide.64function signalTick() {65 if (!state.info || state.info.antenna === false || document.hidden) return;66 const running = !!(state.antenna && (state.antenna.sweep.running || state.antenna.tuner.scanning));67 const banner = state.view === 'tv' && state.current && !state.current.own && $('#banner').classList.contains('show');68 const every = running ? 2000 : receptionShown() || banner ? 10_000 : state.view === 'guide' ? 30_000 : 0;69 if (every && Date.now() - signalAt >= every - 100) loadSignal();70}7172// measureNow starts Measure now on the server.73async function measureNow() {74 const sw = state.antenna && state.antenna.sweep;75 if (sw && sw.running) return toast(`Measuring: ${sw.done} of ${sw.total} RF channels`, 2000);76 try {77 const st = await api().Measure();78 if (state.antenna) state.antenna.sweep = st;79 signalAt = 0;80 renderMeasure();81 } catch (e) {82 toast(String(e && e.message ? e.message : e), 6000);83 }84 return undefined;85}8687// scanChannels has the tuner scan for channels again, on a second press88// within a few seconds: it takes every tuner for a few minutes.89let scanArmed = 0;90async function scanChannels() {91 const t = (state.antenna && state.antenna.tuner) || {};92 if (t.scanning) return toast('The tuner is scanning for channels', 2000);93 if (Date.now() - scanArmed > 4000) {94 scanArmed = Date.now();95 renderScan();96 setTimeout(renderScan, 4100);97 return undefined;98 }99 scanArmed = 0;100 try {101 const ti = await api().ScanChannels();102 if (state.antenna) state.antenna.tuner = ti;103 signalAt = 0;104 renderTunerCard();105 renderScan();106 } catch (e) {107 toast(String(e && e.message ? e.message : e), 6000);108 }109 return undefined;110}111112// renderScan shows the channel scan's button: what it does, and that it113// asks twice.114function renderScan() {115 const a = $('#antenna');116 const t = (state.antenna && state.antenna.tuner) || {};117 const btn = f(a, 'scan');118 btn.classList.toggle('busy', !!t.scanning);119 btn.textContent = t.scanning ? 'Scanning' : 'Scan for channels';120 f(a, 'snote').textContent = t.scanning ? 'The tuner is looking for channels; watching and measuring wait until it is done'121 : Date.now() - scanArmed < 4000 ? 'Press again to scan: it takes every tuner for a few minutes'122 : 'After moving the antenna, or when channels go missing';123}124125function wireAntenna() {126 const a = $('#antenna');127 f(a, 'measure').addEventListener('click', () => measureNow());128 f(a, 'scan').addEventListener('click', () => scanChannels());129 $('.a-table tbody').addEventListener('click', (e) => {130 const tr = e.target.closest('tr[data-rf]');131 if (tr) pickRF(Number(tr.dataset.rf));132 });133 $('.radar').addEventListener('click', (e) => {134 const dot = e.target.closest('[data-rf]');135 if (dot) pickRF(Number(dot.dataset.rf));136 });137}138139// Reception's parts take the focus in turn (data-zone): the RF channels'140// table, and above it a row of the tuner's card, Measure now and Scan. OK141// on an RF channel with channels watches the first. Left from either goes142// back to Settings' tabs.143const antUI = { zone: 'table', key: 'measure' };144145const antHead = () => $$('#antenna .a-head .fx').filter(isShown);146147function antZone(zone, key = '') {148 antUI.zone = zone;149 $('#antenna').dataset.zone = zone;150 if (key) antUI.key = key;151 if (state.dock >= 0 || !receptionShown() || settingsUI.zone !== 'list') return;152 renderRFDetail(); // its OK hint is the table's153 if (zone === 'table') {154 blurIn($('#antenna'));155 scrollRF();156 return;157 }158 const items = antHead();159 focusEl(items.find((el) => el.dataset.act === antUI.key) || items[0]);160}161162function antRefocus() {163 if (!receptionShown() || state.dock >= 0 || settingsUI.zone !== 'list' || antUI.zone !== 'head') return;164 const el = document.activeElement;165 if (!$('#antenna').contains(el) || !isShown(el)) antZone('head');166}167168// antennaKey handles a key in Reception, reporting whether it did.169function antennaKey(e) {170 if (antUI.zone === 'head') {171 const items = antHead();172 const el = document.activeElement;173 const i = items.indexOf(el);174 switch (e.key) {175 case 'ArrowUp': openDock(); break;176 case 'ArrowLeft': if (i > 0) focusEl(items[i - 1]); else settingsZone('tabs'); break;177 case 'ArrowRight': if (i < 0) antZone('head'); else if (i < items.length - 1) focusEl(items[i + 1]); break;178 case 'ArrowDown': if (antRows().length) antZone('table'); break;179 case 'Enter':180 if (el && el.dataset.act === 'measure') measureNow();181 else if (el && el.dataset.act === 'scan') scanChannels();182 else if (i < 0) antZone('head');183 break;184 default: return false;185 }186 // Where the focus comes back to, from the dock.187 const now = document.activeElement;188 if (now && now.dataset.act && $('#antenna').contains(now)) antUI.key = now.dataset.act;189 e.preventDefault();190 return true;191 }192 const rows = antRows();193 let i = rows.indexOf(state.aRF);194 switch (e.key) {195 case 'ArrowUp':196 if (i <= 0) { e.preventDefault(); antZone('head'); return true; }197 i--;198 break;199 case 'ArrowDown': i = Math.min(rows.length - 1, i + 1); break;200 case 'ArrowLeft': e.preventDefault(); settingsZone('tabs'); return true;201 case 'Enter': {202 e.preventDefault();203 const m = state.byRF.get(state.aRF);204 const ch = m && m.channels.length && findChannel(m.channels[0]);205 if (ch) watchChannel(ch);206 return true;207 }208 default: return false;209 }210 e.preventDefault();211 pickRF(rows[Math.max(0, i)]);212 return true;213}214215// antMuxes is the RF channels to list: those the scan found (by RF), then216// those a licensed station nearby uses; others hold nothing to see.217function antMuxes() {218 const all = state.antenna ? state.antenna.muxes : [];219 const found = all.filter(muxFound);220 const rest = all.filter((m) => !muxFound(m) && m.stations.length);221 return [...found, ...rest];222}223const muxFound = (m) => m.channels.length > 0;224const antRows = () => antMuxes().map((m) => m.rf);225const muxSig = (m) => sigOf(m && { signal: m.signal, recent: m.history && m.history.windows.length ? m.history.windows[m.history.windows.length - 1] : null });226227// tunerName names the tuner: "HDHomeRun FLEX DUO".228const tunerName = (t) => (t && t.name) || 'Tuner';229230function renderAntenna() {231 if (!state.report || !state.info || state.info.antenna === false) return;232 const a = $('#antenna');233 const muxes = antMuxes();234 if (!muxes.some((m) => m.rf === state.aRF)) state.aRF = muxes.length ? muxes[0].rf : 0;235 renderTunerCard();236 renderMeasure();237 renderScan();238 const found = muxes.filter(muxFound);239 const locked = found.filter((m) => muxSig(m).state === 'lock').length;240 const chans = state.lineup.filter((c) => !c.own).length;241 f(a, 'stats').innerHTML = `242 <div><dt>Channels</dt><dd>${chans}<small>on ${found.length} RF channels the scan found</small></dd></div>243 ${found.length ? `<div><dt>Locked</dt><dd>${locked}<small>of those ${found.length} at their last reading</small></dd></div>` : ''}`;244 renderRFTable(muxes);245 renderRadar(muxes);246 renderRFDetail();247 renderNextGen();248 antRefocus();249}250251function renderTunerCard() {252 const t = (state.antenna && state.antenna.tuner) || state.info.tuner || {};253 const n = t.tuners || 0;254 const use = t.inUse ? `${t.inUse} of ${n} in use` : n ? `${n > 1 ? 'Both' : 'It'} free` : '';255 f($('#antenna'), 'tuner').innerHTML = `256 <div class="at-label">Tuner</div>257 <div class="at-name">${esc(tunerName(t))}</div>258 <div class="at-line">${n ? `${n} tuner${n > 1 ? 's' : ''}, ${esc((t.standards || []).join(', ') || 'ATSC')}` : 'No tuner'}</div>259 ${use ? `<div class="at-line">${esc(use)}</div>` : ''}260 ${t.scanning ? '<div class="at-line at-warn">Scanning for channels</div>' : ''}261 ${t.reason ? `<div class="at-line at-warn">${esc(t.reason)}</div>` : ''}262 ${t.model ? `<div class="at-model">${esc(t.model)}</div>` : ''}`;263}264265// renderMeasure shows Measure now's progress on its button: the RF266// channel being read, how many are done, the found ones first.267function renderMeasure() {268 const a = $('#antenna');269 const sw = (state.antenna && state.antenna.sweep) || {};270 const btn = f(a, 'measure');271 btn.classList.toggle('busy', !!sw.running);272 f(a, 'mlabel').textContent = sw.running ? (sw.rf ? `Measuring RF ${sw.rf}` : 'Measuring') : 'Measure now';273 f(a, 'mbar').style.width = sw.running && sw.total ? `${Math.round((sw.done / sw.total) * 100)}%` : '0';274 f(a, 'mprog').hidden = !sw.running;275 let note = 'Reads every RF channel on a free tuner, a few seconds each, the ones the scan found first';276 if (sw.running) {277 const found = sw.found || 0;278 note = sw.done < found279 ? `${sw.done} of the ${found} found by the scan, then ${sw.total - found} more nearby stations use`280 : `All ${found} found, now ${sw.done - found} of ${sw.total - found} more nearby stations use`;281 } else if (sw.finishedAt) {282 note = `Last measured ${ago(Date.parse(sw.finishedAt))}, ${sw.done} of ${sw.total} RF channels${sw.note ? `: ${sw.note}` : ''}`;283 }284 f(a, 'mnote').textContent = note;285}286287function renderRFTable(muxes) {288 const rows = [];289 let shownRest = false;290 for (const m of muxes) {291 const found = muxFound(m);292 if (!found && !shownRest) {293 shownRest = true;294 rows.push('<tr class="a-sect"><td colspan="7">Not found by the scan: RF channels nearby stations use</td></tr>');295 }296 const s = muxSig(m);297 const st = muxStation(m);298 const more = m.stations.length > 1 ? `, +${m.stations.length - 1} more` : '';299 rows.push(`<tr data-rf="${m.rf}" class="${m.rf === state.aRF ? 'sel' : ''}${found ? '' : ' nf'}">300 <td class="rf"><b>${m.rf}</b><div class="sub">${m.frequencyMhz} MHz ${esc(m.band)}</div></td>301 <td>${st ? `<div class="call">${esc(st.callSign)}${st.atsc3 ? ' <span class="chip ng">3.0</span>' : ''}</div><div class="sub">${esc(st.city)}, ${st.distanceKm.toFixed(0)} km ${compass(st.bearingDeg)}${more}</div>` : '<div class="sub">No licensed station nearby</div>'}</td>302 <td class="carries">${compactCarries(m.channels).split(' ').filter(Boolean).map((g) => `<span>${esc(g)}</span>`).join(' ')}</td>303 <td class="c">${meter(s)}</td>304 <td class="r mono" style="${s.state === 'off' ? 'color:var(--red)' : ''}">${s.state === 'none' ? '' : s.state === 'off' ? 'No lock' : s.quality || 'Lock'}<div class="sub">${s.strength ? `strength ${s.strength}` : ''}</div></td>305 <td class="r mono">${s.state === 'none' || s.state === 'off' || s.errors == null ? '' : s.errors >= 1 ? `<span class="errs">${Math.round(s.errors)}/s</span>` : '0'}</td>306 <td class="r mono">${s.state === 'none' ? '' : esc(agoShort(s.at))}</td>307 </tr>`);308 }309 $('.a-table tbody').innerHTML = rows.join('') || `<tr class="a-sect"><td colspan="7">${esc((state.antenna && state.antenna.tuner.reason) || 'No RF channels yet')}</td></tr>`;310}311312// muxStation is the transmitter an RF channel's channels come from, else313// the nearest station licensed on it.314function muxStation(m) {315 const tx = state.antennaChans.find((c) => c.rf === m.rf && c.transmitter);316 return (tx && m.stations.find((x) => x.callSign === tx.transmitter)) || m.stations[0] || null;317}318319// pickRF picks an RF channel in the table.320function pickRF(rf) {321 state.aRF = rf;322 $$('.a-table tr[data-rf]').forEach((tr) => tr.classList.toggle('sel', Number(tr.dataset.rf) === rf));323 scrollRF();324 renderRFDetail();325 markRadar();326}327328function scrollRF() {329 const tr = $(`.a-table tr[data-rf="${state.aRF}"]`);330 if (tr) tr.scrollIntoView({ block: 'nearest' });331}332333// renderRFDetail is the picked RF channel: its last reading, its readings334// over time, its channels and the stations licensed on it.335function renderRFDetail() {336 const box = f($('#antenna'), 'detail');337 const m = state.byRF.get(state.aRF);338 if (!m) {339 box.innerHTML = '';340 return;341 }342 const s = muxSig(m);343 const scan = muxFound(m) ? "In the tuner's lineup" : 'Not found by the scan';344 const wins = (m.history && m.history.windows) || [];345 const bars = wins.map((w) => {346 const q = w.qualityPct ? w.qualityPct.avg : 0;347 const lo = w.qualityPct ? w.qualityPct.min : 0;348 const st = w.lockedPct >= 100 ? 'lock' : w.lockedPct > 0 ? 'part' : 'off';349 const tip = `${new Date(w.from).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}: ${w.samples} reading${w.samples > 1 ? 's' : ''}${w.source === 'active' ? ' while watched' : ''}, locked ${Math.round(w.lockedPct)}%${w.qualityPct ? `, quality ${w.qualityPct.min} to ${w.qualityPct.max}%` : ''}`;350 return `<i class="h-${st}" style="--h:${Math.max(4, q)}%;--lo:${lo}%" title="${esc(tip)}"></i>`;351 }).join('');352 const chans = m.channels.map((n) => {353 const c = state.antennaChans.find((x) => x.number === n);354 return `<span><b>${esc(n)}</b> ${esc(c ? displayCall(c) : '')}${c && c.network ? ` ${esc(c.network)}` : ''}</span>`;355 }).join('');356 const stations = m.stations.map((x) => `<li><b>${esc(x.callSign)}</b>${x.virtualChannel ? ` (${x.virtualChannel})` : ''} ${esc(x.city)}, ${x.distanceKm.toFixed(0)} km ${compass(x.bearingDeg)}, ${x.erpKw >= 10 ? x.erpKw.toFixed(0) : x.erpKw.toFixed(1)} kW${x.haatM ? `, ${x.haatM.toFixed(0)} m up` : ''}${x.atsc3 ? ' <span class="chip ng">ATSC 3.0</span>' : ''}</li>`).join('');357 const first = m.channels.length ? m.channels[0] : '';358 box.innerHTML = `359 <div class="ad-head"><b>RF ${m.rf}</b><span>${m.frequencyMhz} MHz ${esc(m.band)}</span><span class="ad-scan">${esc(scan)}</span></div>360 <div class="ad-sig">${meter(s, true)}<div><div class="ad-label" style="color:${s.state === 'none' ? 'var(--muted)' : s.color}">${esc(s.label)}</div><div class="ad-sub">${esc(sigDetail(s))}</div></div></div>361 ${bars ? `<div class="ad-hist" aria-label="Readings over time">${bars}</div><div class="ad-axis"><span>${esc(ago(Date.parse(wins[0].from)))}</span><span>${esc(ago(Date.parse(wins[wins.length - 1].to)))}</span></div>` : ''}362 ${chans ? `<div class="ad-chans">${chans}</div>` : ''}363 ${stations ? `<ul class="ad-stations">${stations}</ul>` : ''}364 ${first && antUI.zone === 'table' ? `<div class="ad-hint">${hints([['Enter', 'Enter', `watches ${first}`]])}</div>` : ''}`;365}366367// compactCarries turns ["4.1","4.2","2.1 (3.0)"] into "4.1-2 3.0: 2.1".368function compactCarries(list) {369 const fold = (items) => {370 const majors = new Map();371 for (const c of items) {372 const [maj, min] = c.split('.');373 if (!majors.has(maj)) majors.set(maj, []);374 majors.get(maj).push(Number(min || 0));375 }376 return [...majors].map(([maj, mins]) => (mins.length > 1 ? `${maj}.${Math.min(...mins)}-${Math.max(...mins)}` : `${maj}${mins[0] ? '.' + mins[0] : ''}`)).join(' ');377 };378 const v1 = list.filter((c) => !c.endsWith('(3.0)'));379 const v3 = list.filter((c) => c.endsWith('(3.0)')).map((c) => c.replace(' (3.0)', ''));380 return [fold(v1), v3.length ? `3.0: ${fold(v3)}` : ''].filter(Boolean).join(' ');381}382383const svgEl = (tag, attrs, text) => {384 const el = document.createElementNS('http://www.w3.org/2000/svg', tag);385 for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v);386 if (text != null) el.textContent = text;387 return el;388};389390// renderRadar places the listed RF channels' transmitters around home, by391// tower site, colored by what was measured on their RF channels.392function renderRadar(muxes) {393 const svg = $('.radar');394 svg.innerHTML = '';395 const R = 200;396 const maxKm = state.report.radiusKm || 160;397 const rad = (km) => R * Math.sqrt(Math.min(km, maxKm) / maxKm);398 const pos = (km, deg) => {399 const a = (deg - 90) * Math.PI / 180;400 return [rad(km) * Math.cos(a), rad(km) * Math.sin(a)];401 };402 for (const km of [25, 50, 100, maxKm]) {403 svg.append(svgEl('circle', { class: `ring${km === maxKm ? ' edge' : ''}`, r: rad(km) }));404 svg.append(svgEl('text', { x: 3, y: -rad(km) - 3 }, `${km} km`));405 }406 svg.append(svgEl('line', { class: 'axis', x1: -R, y1: 0, x2: R, y2: 0 }));407 svg.append(svgEl('line', { class: 'axis', x1: 0, y1: -R, x2: 0, y2: R }));408 for (const [t, x, y] of [['N', 0, -R - 8], ['E', R + 10, 4], ['S', 0, R + 16], ['W', -R - 10, 4]]) {409 svg.append(svgEl('text', { class: 'card', x, y, 'text-anchor': 'middle' }, t));410 }411 // Tower sites: the stations on the listed RF channels, by place.412 const rank = { lock: 3, part: 2, off: 1, none: 0 };413 const sites = new Map();414 for (const m of muxes) {415 const sig = muxSig(m);416 for (const x of m.stations) {417 const full = state.bySite.get(`${x.facilityId}:${m.rf}`);418 if (!full || !full.point) continue;419 const k = `${full.point.lat.toFixed(3)},${full.point.lon.toFixed(3)}`;420 const site = sites.get(k) || { x, rfs: [], calls: [], state: 'none', found: 0 };421 site.rfs.push(m.rf);422 site.calls.push(x.callSign);423 if (rank[sig.state] > rank[site.state]) site.state = sig.state;424 if (muxFound(m)) site.found++;425 sites.set(k, site);426 }427 }428 const labelled = new Map();429 for (const site of sites.values()) {430 if (site.found < 2) continue;431 const city = site.x.city;432 if (!labelled.has(city) || labelled.get(city).found < site.found) labelled.set(city, site);433 }434 const ordered = [...sites.values()].sort((a, b) => rank[a.state] - rank[b.state]);435 const labels = [];436 for (const site of ordered) {437 const [x, y] = pos(site.x.distanceKm, site.x.bearingDeg);438 const r = 3 + Math.min(7, Math.sqrt(site.rfs.length) * 2);439 const dot = svgEl('circle', { class: `site-dot s-${site.state}`, cx: x, cy: y, r, 'data-rf': site.rfs[0] });440 dot.dataset.rfs = site.rfs.join(' ');441 dot.append(svgEl('title', {}, `${site.calls.join(', ')}\n${site.x.city}, ${site.x.distanceKm.toFixed(0)} km ${compass(site.x.bearingDeg)}, RF ${site.rfs.join(', ')}`));442 svg.append(dot);443 if ([...labelled.values()].includes(site)) labels.push({ x, y, r, text: `${site.x.city} ${site.found}` });444 }445 const placed = [];446 for (const l of labels.sort((a, b) => a.text.length - b.text.length)) {447 const left = l.x < 0;448 const w = l.text.length * 7.8;449 const x0 = left ? l.x - l.r - 5 - w : l.x + l.r + 5;450 const box = { x0, x1: x0 + w, y0: l.y - 9, y1: l.y + 6 };451 if (box.x0 < -224 || box.x1 > 224 || placed.some((b) => box.x0 < b.x1 && b.x0 < box.x1 && box.y0 < b.y1 && b.y0 < box.y1)) continue;452 placed.push(box);453 svg.append(svgEl('text', { class: 'site', x: left ? l.x - l.r - 5 : l.x + l.r + 5, y: l.y + 4, 'text-anchor': left ? 'end' : 'start' }, l.text));454 }455 svg.append(svgEl('circle', { class: 'home', r: 4 }));456 svg.append(svgEl('line', { class: 'ray', id: 'ray', x1: 0, y1: 0, x2: 0, y2: 0 }));457 markRadar();458}459460// markRadar marks the picked RF channel's transmitter, with a ray to it.461function markRadar() {462 $$('.radar .site-dot').forEach((d) => d.classList.toggle('sel', (d.dataset.rfs || '').split(' ').includes(String(state.aRF))));463 const ray = $('#ray');464 const m = state.byRF.get(state.aRF);465 const st = m && muxStation(m);466 if (!ray) return;467 if (!st) {468 ray.setAttribute('x2', 0);469 ray.setAttribute('y2', 0);470 return;471 }472 const maxKm = state.report.radiusKm || 160;473 const rr = 200 * Math.sqrt(Math.min(st.distanceKm, maxKm) / maxKm);474 const a = (st.bearingDeg - 90) * Math.PI / 180;475 ray.setAttribute('x2', rr * Math.cos(a));476 ray.setAttribute('y2', rr * Math.sin(a));477}478479// renderNextGen lists the stations broadcasting ATSC 3.0 here, as facts480// about them: what this tuner can't take in.481function renderNextGen() {482 const box = f($('#antenna'), 'nextgen');483 const hosts = state.report.atsc3 || [];484 if (!hosts.length) {485 box.innerHTML = '';486 return;487 }488 const items = hosts.map((h) => {489 const svcs = (h.services || []).map((v) => `<span><i>${esc(v.display.replace('-', '.').replace(/^0/, ''))}</i> ${esc(v.network || v.name)}</span>`).join(' ');490 return `<div class="ng-host"><b>${esc(h.callSign)}</b> <em>RF ${esc(h.rf)}</em> ${svcs}</div>`;491 }).join('');492 box.innerHTML = `<div class="ng-label">NextGen TV (ATSC 3.0) on the air here${nextGen() ? '' : '<small>This tuner takes in ATSC 1.0 only</small>'}</div><div class="ng-hosts">${items}</div>`;493}