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 / weather.js
weather.js 12.9K
1'use strict';23// The weather channel and the Weather view.45// ---------- weather ----------6// showWX puts the Airwaves Weather display on the stage: as the weather channel7// (with its music), or as a silent preview on the weather page. The display8// itself is always silent; the music plays here, so muting is instant.9function showWX(on, preview = false) {10 const frame = $('#wx');11 frame.hidden = !on;12 const src = `${state.boot.serverUrl}/weatherstar`;13 if (on && frame.dataset.src !== src) {14 frame.src = src;15 frame.dataset.src = src;16 }17 if (!on && frame.dataset.src) {18 frame.removeAttribute('src');19 delete frame.dataset.src;20 }21 $('#video').hidden = on;22 if (on) fitWX();23 playWXMusic(on && !preview);24}2526// fitWX scales the weather display to fill the stage. It is laid out for27// 854x480 and scales itself to its window; the interface size scaled it28// again, off centre. So its window is always 854x480 and the frame is29// scaled here instead, at every stage size. Page zoom (Linux) leaves the30// frame's window at 854x480; CSS zoom would shrink it, so the frame undoes31// the root's CSS zoom and is sized in its own, unzoomed pixels.32const WX_W = 854;33const WX_H = 480;34function fitWX() {35 const z = parseFloat(document.documentElement.style.zoom) || 1;36 const w = stage.clientWidth * z;37 const h = stage.clientHeight * z;38 if (!w || !h) return;39 const s = Math.min(w / WX_W, h / WX_H);40 const frame = $('#wx');41 frame.style.zoom = z === 1 ? '' : String(1 / z);42 frame.style.transform = `translate(${(w - WX_W * s) / 2}px, ${(h - WX_H * s) / 2}px) scale(${s})`;43}44if (window.ResizeObserver) new ResizeObserver(fitWX).observe(stage);4546const wxMusic = new Audio();47wxMusic.volume = 0.6;48wxMusic.addEventListener('ended', () => nextWXTrack());4950// playWXMusic starts or stops the weather channel's music: the server's51// tracks, shuffled and looped.52async function playWXMusic(on) {53 state.wxMusicOn = on;54 if (!on) return wxMusic.pause();55 if (!state.wxTracks) {56 try {57 const r = await fetch(`${state.boot.serverUrl}/weatherstar/music`);58 state.wxTracks = ((await r.json()).tracks || []).map((t) => (t.startsWith('/') ? state.boot.serverUrl + t : t));59 } catch (e) {60 log('warn', `weather music: ${e}`);61 state.wxTracks = [];62 }63 }64 if (!state.wxMusicOn || !state.wxTracks.length) return;65 wxMusic.muted = !!state.userMuted;66 if (!wxMusic.src) return nextWXTrack();67 wxMusic.play().catch(() => {});68}6970function nextWXTrack() {71 const tracks = state.wxTracks || [];72 if (!tracks.length || !state.wxMusicOn) return;73 state.wxTrack = ((state.wxTrack ?? Math.floor(Math.random() * tracks.length)) + 1) % tracks.length;74 wxMusic.src = tracks[state.wxTrack];75 wxMusic.play().catch((e) => log('warn', `weather music: ${e}`));76}7778async function loadWeather() {79 try {80 state.wx = await api().Weather();81 } catch (e) {82 log('warn', `weather: ${e}`);83 return;84 }85 renderCrawl();86 if (state.view === 'weather') renderWeather();87 if (state.current && state.current.weather) renderBanner();88}8990const deg = (v) => (v == null ? '--' : `${Math.round(v)}°`);91const tclock = clock;9293function renderWXBanner(b, ch) {94 const wx = state.wx || {};95 const n = wx.now || {};96 const [p0, p1] = wx.periods || [];97 b.classList.remove('warn', 'detail');98 f(b, 'num').textContent = ch.number;99 f(b, 'num').classList.toggle('long', ch.number.length > 4);100 f(b, 'call').textContent = displayCall(ch);101 f(b, 'net').textContent = ch.network;102 showChannelLogo(b, ch);103 f(b, 'time').textContent = n.observed ? `Observed ${tclock(n.observed)}` : '';104 f(b, 'chips').innerHTML = (wx.alerts || []).map((a) => `<span class="chip live">${esc(a.event)}</span>`).join('');105 f(b, 'title').textContent = n.tempF != null ? `${deg(n.tempF)} ${n.description || ''}` : 'Local Forecast';106 f(b, 'ep').textContent = p0 ? `${p0.name}: ${p0.short}, ${p0.isDay ? 'high' : 'low'} ${p0.tempF}°` : '';107 f(b, 'progress').style.width = '0';108 f(b, 'desc').textContent = p0 ? p0.detailed : '';109 f(b, 'next').textContent = p1 ? `${p1.name}: ${p1.short}, ${p1.tempF}°` : '';110 f(b, 'note').textContent = '';111}112113// Watches and warnings crawl across live TV; advisories stay on the W page.114function crawlAlerts() {115 return ((state.wx && state.wx.alerts) || []).filter((a) => /warning|watch/i.test(a.event) || a.severity === 'Severe' || a.severity === 'Extreme');116}117118function renderCrawl() {119 const alerts = crawlAlerts();120 const c = $('#crawl');121 document.body.classList.toggle('has-crawl', alerts.length > 0);122 if (!alerts.length) return;123 c.classList.toggle('severe', alerts.some((a) => /warning/i.test(a.event)));124 const text = alerts.map((a) => `${a.event.toUpperCase()}${a.ends ? ` until ${new Date(a.ends).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}` : ''}. ${a.headline || ''}`).join(' | ');125 const span = f(c, 'text');126 if (span.textContent !== text) {127 span.textContent = text;128 // Speed scales with length: about 90 px a second.129 span.style.animationDuration = `${Math.max(20, (text.length * 9 + window.innerWidth) / 90)}s`;130 }131}132133// ---------- weather view ----------134// Its parts take the focus in turn, Up and Down, scrolling with it: the135// alerts, the next 24 hours, the week, the maps. Left goes to Watch the136// weather channel in the rail, when there is one, and from there to the137// dock, as does Up from the first part.138const wxUI = { at: 0 };139const wxBlocks = () => $$('#weather .wx-main > .fx').filter(isShown);140const wxWatch = () => $('#weather .wx-watch');141142function wireWeather() {143 const v = $('#weather');144 wxWatch().addEventListener('click', () => {145 const ch = weatherChannel();146 if (!ch) return;147 if (!(state.current && state.current.weather && !state.recording)) tune(ch);148 setView('tv');149 });150 $('.wx-main', v).addEventListener('focusin', (e) => {151 const i = wxBlocks().indexOf(e.target);152 if (i >= 0) wxUI.at = i;153 });154}155156function wxFocus() {157 if (state.dock >= 0 || state.view !== 'weather') return;158 const blocks = wxBlocks();159 const i = Math.min(wxUI.at, blocks.length - 1);160 if (i < 0) return void (isShown(wxWatch()) && focusEl(wxWatch()));161 focusEl(blocks[i]);162 if (i === 0) $('#weather .wx-main').scrollTop = 0;163}164165function weatherKey(e) {166 const el = document.activeElement;167 const blocks = wxBlocks();168 if (el === wxWatch()) {169 switch (e.key) {170 case 'ArrowRight': if (blocks.length) wxFocus(); break;171 case 'ArrowLeft': case 'ArrowUp': openDock(); break;172 case 'ArrowDown': break;173 case 'Enter': el.click(); break;174 default: return;175 }176 e.preventDefault();177 return;178 }179 const i = blocks.indexOf(el);180 switch (e.key) {181 case 'ArrowUp':182 if (i > 0) { wxUI.at = i - 1; wxFocus(); } else openDock();183 break;184 case 'ArrowDown':185 if (i < 0) wxFocus();186 else if (i < blocks.length - 1) { wxUI.at = i + 1; wxFocus(); }187 break;188 case 'ArrowLeft': if (isShown(wxWatch())) focusEl(wxWatch()); else openDock(); break;189 case 'ArrowRight': if (i < 0) wxFocus(); break;190 default: return;191 }192 e.preventDefault();193}194195function renderWeather() {196 const v = $('#weather');197 wxWatch().hidden = !weatherChannel();198 const wx = state.wx;199 if (!wx) {200 f(v, 'now').innerHTML = '<dl class="rail-stats"><div><dt>Weather</dt><dd>Loading</dd></div></dl>';201 return;202 }203 const n = wx.now || {};204 const air = wx.air;205 const aqiColor = air ? (air.aqi <= 50 ? 'var(--t-strong)' : air.aqi <= 100 ? '#e8d24a' : air.aqi <= 150 ? '#f08a24' : 'var(--red)') : '';206 f(v, 'now').innerHTML = `207 <div class="wx-temp">${deg(n.tempF)}</div>208 <div class="wx-desc">${esc(n.description || '')}</div>209 <dl class="rail-stats wx-facts">210 ${n.feelsLikeF != null ? `<div><dt>Feels like</dt><dd>${deg(n.feelsLikeF)}</dd></div>` : ''}211 <div><dt>Wind</dt><dd>${n.windMph != null ? `${esc(n.windDir)} ${Math.round(n.windMph)}<small>mph${n.gustMph ? `, gusts ${Math.round(n.gustMph)}` : ''}</small>` : '--'}</dd></div>212 ${n.humidity != null ? `<div><dt>Humidity</dt><dd>${Math.round(n.humidity)}%<small>dew point ${deg(n.dewpointF)}</small></dd></div>` : ''}213 ${air ? `<div><dt>Air quality</dt><dd style="color:${aqiColor}">${air.aqi}<small>${esc(air.category)}</small></dd></div>` : ''}214 ${wx.sun ? `<div><dt>Sun</dt><dd class="wx-sun">${tclock(wx.sun.rise)}<small>sets ${tclock(wx.sun.set)}</small></dd></div>` : ''}215 </dl>216 <div class="wx-updated">${n.observed ? `Observed ${tclock(n.observed)}` : ''}</div>`;217218 f(v, 'alerts').innerHTML = (wx.alerts || []).map((a) => `219 <article class="wx-alert ${/warning/i.test(a.event) ? 'severe' : ''}">220 <div class="wx-alert-event">${esc(a.event)}</div>221 <div class="wx-alert-when">${a.ends ? `Until ${new Date(a.ends).toLocaleString([], { weekday: 'long', hour: 'numeric', minute: '2-digit' })}` : ''}</div>222 <p>${esc((a.description || a.headline || '').split('\n\n')[0])}</p>223 </article>`).join('');224225 // Hourly: temperature line over precipitation bars, 24 hours.226 const hours = (wx.hourly || []).slice(0, 24);227 const svg = f(v, 'chart');228 if (hours.length > 1) {229 const W = 960; const H = 200; const pad = 24;230 const temps = hours.map((h) => h.tempF);231 const lo = Math.min(...temps) - 3; const hi = Math.max(...temps) + 3;232 const x = (i) => (i / (hours.length - 1)) * W;233 const y = (t) => pad + (1 - (t - lo) / (hi - lo)) * (H - pad * 2);234 const bars = hours.map((h, i) => (h.precip ? `<rect class="wx-pop" x="${x(i) - 14}" width="28" y="${H - (h.precip / 100) * (H - pad)}" height="${(h.precip / 100) * (H - pad)}"/>` : '')).join('');235 const night = hours.map((h, i) => (!h.isDay ? `<rect class="wx-night" x="${x(i) - W / hours.length / 2}" width="${W / hours.length + 1}" y="0" height="${H}"/>` : '')).join('');236 const line = hours.map((h, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)} ${y(h.tempF).toFixed(1)}`).join(' ');237 svg.innerHTML = `${night}${bars}<path class="wx-line" d="${line}"/>`;238 f(v, 'hours').innerHTML = hours.map((h, i) => (i % 3 === 0 ? `<div style="left:${(i / (hours.length - 1)) * 100}%"><b>${h.tempF}°</b>${new Date(h.start).toLocaleTimeString([], { hour: 'numeric' })}${h.precip >= 20 ? `<em>${h.precip}%</em>` : ''}</div>` : '')).join('');239 }240241 // 7-day: pair each day with the night after it.242 const days = [];243 for (const p of wx.periods || []) {244 if (p.isDay || !days.length) days.push({ day: p.isDay ? p : null, night: p.isDay ? null : p });245 else days[days.length - 1].night = p;246 }247 f(v, 'days').innerHTML = days.slice(0, 7).map(({ day, night }) => {248 const main = day || night;249 const name = day ? (day.name === 'This Afternoon' || day.name === 'Today' ? 'Today' : day.name.slice(0, 3)) : main.name;250 return `<div class="wx-day">251 <div class="wx-dname">${esc(name)}</div>252 <div class="wx-hilo">${day ? `<b>${day.tempF}°</b>` : ''}${night ? `<span>${night.tempF}°</span>` : ''}</div>253 <div class="wx-short">${esc(main.short)}</div>254 ${Math.max(day ? day.precip : 0, night ? night.precip : 0) >= 20 ? `<div class="wx-precip">${Math.max(day ? day.precip : 0, night ? night.precip : 0)}%</div>` : ''}255 </div>`;256 }).join('');257258 const bust = Math.floor(Date.now() / (4 * MIN));259 f(v, 'radar').src = `${state.boot.serverUrl}${wx.radar}?t=${bust}`;260 f(v, 'satellite').src = `${state.boot.serverUrl}${wx.satellite}?t=${bust}`;261 // An alert that went had the focus, perhaps.262 if (state.view === 'weather' && !v.contains(document.activeElement)) wxFocus();263}264265// wxPrograms is the weather channel's guide: hour-long "Local Forecast" blocks266// carrying that hour's forecast, matching what airwavesd publishes in XMLTV.267function wxPrograms() {268 const wx = state.wx;269 if (!wx) return [];270 const ch = weatherChannel();271 const genres = [...new Set(['weather', ...categoryGenres(ch && ch.category)])];272 if (state.wxProgs && state.wxProgs.at === wx.updated && state.wxProgs.genres === genres.join()) return state.wxProgs.list;273 const H = 3600_000;274 const first = Math.floor((Date.now() - H) / H) * H;275 const hourly = (wx.hourly || []).map((h) => ({ ...h, t: Date.parse(h.start) }));276 const periods = (wx.periods || []).map((p) => ({ ...p, t: Date.parse(p.start) }));277 const list = [];278 for (let i = 0; i < 48; i++) {279 const s = first + i * H;280 const h = hourly.find((x) => x.t <= s && s < x.t + H);281 const pi = periods.findIndex((p, j) => p.t <= s && s < (periods[j + 1] ? periods[j + 1].t : p.t + 12 * H));282 const alert = (wx.alerts || []).find((a) => (!a.onset || Date.parse(a.onset) < s + H) && (!a.ends || Date.parse(a.ends) > s));283 let sub = h ? `${h.tempF}° and ${h.short}` : '';284 if (alert) sub = `${alert.event}. ${sub}`;285 list.push({286 start: new Date(s).toISOString(), end: new Date(s + H).toISOString(), _s: s, _e: s + H,287 title: 'Local Forecast', episodeTitle: sub,288 description: pi >= 0 ? `${periods[pi].name}: ${periods[pi].detailed}` : 'Current conditions, the local and extended forecast, and radar.',289 genres,290 });291 }292 state.wxProgs = { at: wx.updated, genres: genres.join(), list };293 return list;294}