// ════════════════════════════════════════════════════════════════════════ // app.js — Main UTCIForecast component. // // This is the top-level Preact component that owns all state, fetches // the forecast, runs the per-hour computations, and renders the page. // // Reading order inside UTCIForecast(): // 1. STATE (useState calls) — bits that change on interaction // 2. EFFECTS (useEffect calls) — runs on search / location change // 3. COMPUTATION (hourlyRows, days, …) — API data → display rows // 4. JSX RETURN (the big html`...`) — actual page markup // // QUICK MAP // ────────────────────────────────────────────────────────────────────── // Forecast length .............. fetch URL contains &forecast_days=14 // Free tier day limit .......... const FREE_DAYS = 3 // Preview the Pro view ......... useState(false) on isPro → flip to true // Starting location ............ useState({...}) on `location` near top // Default columns shown ........ useState({...}) on visibleCols // Page tagline / about copy .... search "utci-tagline" or "utci-about-text" // ════════════════════════════════════════════════════════════════════════ import { h, render, Fragment } from '../vendor/preact.js'; import { useState, useRef, useEffect } from '../vendor/preact-hooks.js'; import htm from '../vendor/htm.js'; import { utciCategory, UTCI_BANDS, bandGradient, SKIN_TYPES, sunburnMinutes, burnLabel, VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES, FUR_COLORS, pawBurnRiskLabel, confidenceBand, moonGlyph, skyFillForElev, } from './utils.js'; import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js'; import { getCellTagEvents, getUpcomingEvents } from './events.js'; import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS } from './config.js'; import { useAppState } from './hooks/useAppState.js'; import { DayTabs } from './components/DayTabs.js'; import { WelcomeModal } from './components/WelcomeModal.js'; import { RestoreModal } from './components/RestoreModal.js'; import { computeWhyFeelsLike, computeGlanceSummary } from './compute.js'; import { solarElevationDeg } from './physics.js'; import { exportDayXls } from './export.js'; const html = htm.bind(h); // ── Scope time-lapse playback ─────────────────────────────────────────── // Drives the big scope through the next 24h so you can watch the sky/ground // gradients and weather evolve. Speed: 15 simulated minutes every 1 real // second (≈ a full day in ~1.6 minutes). UI advances smoothly every 200ms. const PLAYBACK_SIM_MS_PER_REAL_MS = 900; // 15 min / 1 s = 900× const PLAYBACK_STEP_MS = 200; // UI update cadence const PLAYBACK_WINDOW_MS = 24 * 60 * 60 * 1000; // Interpolates the hourly rows to an arbitrary instant (ms). Returns a row // with smoothly blended elevation / cloud / precip so the scope animates // continuously rather than snapping hour to hour. function interpolateRowAt(rows, t) { if (!rows || rows.length === 0) return null; if (t <= +rows[0].dt) return rows[0]; if (t >= +rows[rows.length - 1].dt) return rows[rows.length - 1]; let lo = rows[0], hi = rows[1]; for (let i = 1; i < rows.length; i++) { if (+rows[i].dt >= t) { lo = rows[i - 1]; hi = rows[i]; break; } } const f = (t - +lo.dt) / (+hi.dt - +lo.dt); const L = (a, b) => (a == null || b == null) ? (a ?? b) : a + (b - a) * f; return { ...lo, dt: new Date(t), elev: L(lo.elev, hi.elev), glob: L(lo.glob, hi.glob), utciAdj: L(lo.utciAdj, hi.utciAdj), // Felt-temp inputs — interpolated so the "Why It Feels" panel breakdown // stays smooth and consistent with the (interpolated) utciAdj category. Ta: L(lo.Ta, hi.Ta), Tmrt: L(lo.Tmrt, hi.Tmrt), va: L(lo.va, hi.va), eh: L(lo.eh, hi.eh), cc: L(lo.cc, hi.cc), ccLow: L(lo.ccLow, hi.ccLow), ccMid: L(lo.ccMid, hi.ccMid), ccHigh: L(lo.ccHigh, hi.ccHigh), precip: L(lo.precip, hi.precip), snow: L(lo.snow, hi.snow), visKm: L(lo.visKm, hi.visKm), }; } // ── Draggable 24-hour timeline ────────────────────────────────────────── function DayTimeline({ windowStart, windowEnd, simMs, setSimMs, hourlyRows, utcOffsetMs }) { const trackRef = useRef(null); const draggingRef = useRef(false); const totalMs = windowEnd - windowStart; const toFrac = (ms) => Math.max(0, Math.min(1, (ms - windowStart) / totalMs)); const fraction = simMs != null ? toFrac(simMs) : null; // Find sunrise/sunset within the window by scanning hourly elevation sign changes let sunriseMs = null, sunsetMs = null; const winRows = hourlyRows.filter(r => +r.dt >= windowStart - 3600000 && +r.dt <= windowEnd + 3600000); for (let i = 1; i < winRows.length; i++) { const a = winRows[i - 1], b = winRows[i]; if (a.elev <= 0 && b.elev > 0 && sunriseMs === null) { const f = (-a.elev) / (b.elev - a.elev); const t = +a.dt + f * (+b.dt - +a.dt); if (t >= windowStart && t <= windowEnd) sunriseMs = t; } if (a.elev > 0 && b.elev <= 0 && sunsetMs === null) { const f = a.elev / (a.elev - b.elev); const t = +a.dt + f * (+b.dt - +a.dt); if (t >= windowStart && t <= windowEnd) sunsetMs = t; } } const srFrac = sunriseMs != null ? toFrac(sunriseMs) : null; const ssFrac = sunsetMs != null ? toFrac(sunsetMs) : null; // Build gradient from actual per-hour sky colours so the bar mirrors what // the scope would show at each moment across the window. const bgGradient = (() => { if (!winRows.length) return '#0a0810'; const stops = winRows.map(r => { const pct = (toFrac(+r.dt) * 100).toFixed(2); const col = skyFillForElev(r.elev, r.dt.getUTCHours() < 12); return `${col} ${pct}%`; }); return `linear-gradient(to right, ${stops.join(', ')})`; })(); // Tick labels at 0h / 6h / 12h / 18h / 24h of the window (actual local clock times) const ticks = [0, 6, 12, 18, 24].map(h => { const d = new Date(windowStart + h * 3600000 + utcOffsetMs); return d.toISOString().slice(11, 16); }); const msFromClientX = (clientX) => { const rect = trackRef.current.getBoundingClientRect(); return windowStart + Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * totalMs; }; const onPointerDown = (e) => { if (e.button !== 0) return; e.preventDefault(); try { trackRef.current.setPointerCapture(e.pointerId); } catch (_) {} draggingRef.current = true; document.body.classList.add('tl-scrubbing'); setSimMs(msFromClientX(e.clientX)); }; const onPointerMove = (e) => { if (!draggingRef.current) return; setSimMs(msFromClientX(e.clientX)); }; const onPointerUp = () => { draggingRef.current = false; document.body.classList.remove('tl-scrubbing'); }; const onPointerCancel = onPointerUp; return html`
${srFrac != null && html``} ${ssFrac != null && html``} ${fraction != null && html`
`}
`; } export function UTCIForecast() { // ── STATE + EFFECTS ─────────────────────────────────────────────────── // All useState, useEffect, useCallback and useRef logic lives in // useAppState. See hooks/useAppState.js for the full reading order. const { location, setLocationAndSave, recentLocations, forecast, airQuality, loading, error, now, fetchedAt, normals, searchQuery, setSearchQuery, searchResults, setSearchResults, searching, selectedDay, setSelectedDay, proPromptDay, setProPromptDay, proPromptSource, setProPromptSource, dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs, isPro, setIsPro, activeProfile, setActiveProfile, activateProfile, activeCols, visibleCols, setVisibleCols, toggleCol, showDecimals, toggleShowDecimals, welcomeOpen, closeWelcome, openWelcome, restoreOpen, openRestore, closeRestore, showUnits, toggleShowUnits, tableInterval, setTableInterval, forecastView, setForecastView, activityOptions, placeOptions, workOptions, activityValue, activityLabel, placeValue, placeLabel, workValue, workLabel, skinType, setSkinType, vehicleType, setVehicleType, vehicleVent, setVehicleVent, vehicleSpeed, setVehicleSpeed, outdoorsVariant, setOutdoorsVariantAndSave, buildingType, setBuildingType, furColor, setFurColor, indoorManaged, setIndoorManaged, indoorMode, setIndoorMode, pollenType, setPollenTypeAndSave, utciEnv, setUtciEnv, headStickyRef, headTrackRef, headTableRef, bodyScrollRef, bodyTableRef, tableWrapRef, colPopup, colPopupRef, handleThClick, handleThEnter, handleThLeave, handlePopupEnter, handlePopupLeave, eventTagPopup, eventTagPopupRef, evSlideIndex, evTransition, evSlideTo, handleEventTagClick, handleEventTagEnter, handleEventTagLeave, handleEventTagPopupEnter, handleEventTagPopupLeave, closePopup, closeEventTagPopup, tableCanScrollLeft, tableCanScrollRight, handleBodyScroll, hourlyRows, days, utcOffsetMs, visible, tableRows, nowLocalISO, currentRow, currentCat, liveElev, activeEvents, lensEvent, selectedDayEvents, bannerIndex, bannerTransition, bannerPrevIndex, bannerVisible, bannerStageRef, bannerSlideTo, dismissBanner, } = useAppState(); // Search is hidden by default and revealed via the magnifier next to the // location. The input then overlays the location line to save space. const [searchOpen, setSearchOpen] = useState(false); // While closing, keep the overlay mounted so it can fade/close out before // unmounting; cleared when the exit animation ends. const [searchClosing, setSearchClosing] = useState(false); const openSearch = () => { setSearchClosing(false); setSearchOpen(true); }; const closeSearch = () => { setSearchOpen(false); setSearchClosing(true); }; const searchWrapRef = useRef(null); const [colTogglesOpen, setColTogglesOpen] = useState(false); const searchInputRef = useRef(null); const fscScrollRef = useRef(null); useEffect(() => { const el = fscScrollRef.current; if (!el) return; let isDown = false, startX = 0, startScroll = 0, hasDragged = false; const onMouseDown = (e) => { if (!el.contains(e.target) || e.button !== 0) return; isDown = true; hasDragged = false; startX = e.clientX; startScroll = el.scrollLeft; document.body.style.userSelect = 'none'; document.body.style.webkitUserSelect = 'none'; }; const onMouseMove = (e) => { if (!isDown) return; const dx = e.clientX - startX; if (Math.abs(dx) > 5) { hasDragged = true; el.style.cursor = 'grabbing'; el.scrollLeft = startScroll - dx; } }; const onMouseUp = () => { if (!isDown) return; isDown = false; el.style.cursor = ''; document.body.style.userSelect = ''; document.body.style.webkitUserSelect = ''; }; const onClickCapture = (e) => { if (hasDragged) { e.stopPropagation(); e.preventDefault(); hasDragged = false; } }; document.addEventListener('mousedown', onMouseDown); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); el.addEventListener('click', onClickCapture, true); return () => { document.removeEventListener('mousedown', onMouseDown); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); el.removeEventListener('click', onClickCapture, true); }; }, []); const [simpleTemp, setSimpleTemp] = useState('utciAdj'); useEffect(() => { if (activeProfile === 'vehicle') setSimpleTemp('vehicleT'); else if (activeProfile === 'home') setSimpleTemp('indoorT'); else setSimpleTemp('utciAdj'); if (['alltemps', 'showall', 'custom', 'farming', 'construction', 'market', 'windowcleaning', 'office'].includes(activeProfile)) { setForecastView('table'); } }, [activeProfile]); // Focus the input the moment the search opens. useEffect(() => { if (searchOpen) searchInputRef.current?.focus(); }, [searchOpen]); // Close the search on outside-click or Escape, clearing any stray query. useEffect(() => { if (!searchOpen) return; const onDown = (e) => { if (searchWrapRef.current && !searchWrapRef.current.contains(e.target)) { closeSearch(); } }; const onKey = (e) => { if (e.key === 'Escape') { closeSearch(); } }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; }, [searchOpen, setSearchQuery]); // ─── SCOPE TIME-LAPSE ────────────────────────────────────────────────── // `playing` toggles the time-lapse; `simMs` is the simulated instant shown. const [playing, setPlaying] = useState(false); const [simMs, setSimMs] = useState(null); // Reset scope to live when the user switches days (scope window is always // "now → +24h" regardless of selected day, so a stale scrub position is confusing). useEffect(() => { if (!playing) setSimMs(null); }, [selectedDay]); useEffect(() => { if (!playing) return; const start = now.getTime(); const end = start + PLAYBACK_WINDOW_MS; setSimMs(prev => (prev == null || prev < start || prev > end) ? start : prev); const id = setInterval(() => { setSimMs(prev => { let next = (prev == null ? start : prev) + PLAYBACK_STEP_MS * PLAYBACK_SIM_MS_PER_REAL_MS; if (next > end) next = start; // loop back to "now" return next; }); }, PLAYBACK_STEP_MS); return () => clearInterval(id); }, [playing]); // Columns that mark the start of a logical group - used to draw a faint // vertical border separating groups in the forecast table. const GROUP_ORDER = { felt: ['utciP', 'vehicleT', 'indoorT', 'managedT', 'petHomeT', 'burn', 'utci', 'delta', 'tmrt'], surface: ['concreteT', 'soilT', 'soilT6', 'soilM', 'furSurfaceT', 'pawT'], ambient: ['shadeT', 'petShadeT', 'air', 'rh', 'dew'], precip: ['precip', 'precipProb', 'lightning'], sky: ['cloud', 'vis'], wind: ['wind', 'dir'], airqual: ['aqi', 'pollen'], solar: ['uvA', 'uvB', 'sun', 'direct', 'diffuse'], }; const GROUP_OF = Object.fromEntries( Object.entries(GROUP_ORDER).flatMap(([g, keys]) => keys.map(k => [k, g])) ); // Returns col-group-start when this column is the leftmost VISIBLE member // of its group. If the canonical first member is hidden the border migrates // to the next visible column in the same group. const isColVisible = (k) => { if (k === 'indoorT') return indoorMode === 'on' && !indoorManaged; if (k === 'managedT') return indoorMode === 'on' && indoorManaged; return !!visibleCols[k]; }; const groupStart = (key) => { const group = GROUP_OF[key]; if (!group) return ''; const first = GROUP_ORDER[group].find(isColVisible); return first === key ? 'col-group-start' : ''; }; // Returns a CSS class encoding the group name - used to tint header cells // and group label spans. const groupColor = (key) => { const g = GROUP_OF[key]; return g ? `grp-${g}` : ''; }; // Builds the group label row above the column headers. // Each visible group gets one spanning cell; groups with no visible // columns are skipped entirely. Hour always gets a blank lead cell. const groupLabelRow = () => { const groups = Object.keys(GROUP_ORDER); const cells = [html`${glanceDate}`]; for (const g of groups) { const span = GROUP_ORDER[g].filter(isColVisible).length; if (span === 0) continue; const labels = { felt: 'Felt', surface: 'Surface', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' }; cells.push(html`${labels[g]}`); } return html`${cells}`; }; // Continuous temperature colour scale - hoisted so both the table columns // and the thermal stress legend can share the same function. const TEMP_STOPS = [ [-10, [ 90, 155, 220]], [ 0, [140, 195, 235]], [ 10, [155, 215, 195]], [ 16, [140, 210, 140]], [ 20, [195, 225, 110]], [ 24, [240, 225, 80]], [ 28, [250, 175, 65]], [ 32, [240, 120, 55]], [ 36, [220, 70, 50]], [ 40, [185, 30, 30]], [ 50, [130, 0, 20]], ]; const airTempRgb = (t, whiteMix = 0.58) => { if (t == null) return null; const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); let i = TEMP_STOPS.findIndex(s => t < s[0]); if (i === -1) i = TEMP_STOPS.length; const raw = i === 0 ? TEMP_STOPS[0][1] : i >= TEMP_STOPS.length ? TEMP_STOPS[TEMP_STOPS.length-1][1] : (() => { const [t0, c0] = TEMP_STOPS[i-1]; const [t1, c1] = TEMP_STOPS[i]; const x = clamp((t - t0) / (t1 - t0), 0, 1); const lerp = (a, b) => Math.round(a + (b - a) * x); return [lerp(c0[0],c1[0]), lerp(c0[1],c1[1]), lerp(c0[2],c1[2])]; })(); return raw.map(c => Math.min(255, Math.round(c + (255 - c) * whiteMix))); }; const airTempRgbStrong = (t) => airTempRgb(t, 0.42); const airTempRgbVeryStrong = (t) => airTempRgb(t, 0.25); // ─── 3b. PANEL COMPUTATIONS ────────────────────────────────────────── // whyFeelsLike is derived below, after the playback rows are resolved, so // the panel can track the simulated instant during play/scrub (see panelRow). // Interpolated row at the precise current instant — used for the scope // display even outside playback so there's no jump when play is pressed. const nowRow = interpolateRowAt(hourlyRows, now.getTime()); // 24-hour window anchored to now — shared by the play effect and the timeline. const windowStart = now.getTime(); const windowEnd = windowStart + PLAYBACK_WINDOW_MS; // simMs drives the scope whether set by auto-play or by dragging the timeline. const simRow = simMs != null ? interpolateRowAt(hourlyRows, simMs) : null; const scopeRow = simRow || nowRow || currentRow; // Elevation computed continuously from the simulated instant so the sun // starts exactly where liveElev left off (same solarElevationDeg call). const scopeElev = (simMs != null && location?.lat != null) ? solarElevationDeg(location.lat, location.lon, new Date(simMs)) : (liveElev ?? currentRow?.elev ?? 0); const scopeDt = simRow ? simRow.dt : now; const scopeCat = scopeRow ? utciCategory(scopeRow.utciAdj) : currentCat; // "Why It Feels" panel always mirrors the scope dial: the simulated instant // during play/scrub, and the interpolated "now" row (scopeRow → nowRow) when // stopped. Sharing scopeRow/scopeCat guarantees the panel's thermal label can // never disagree with the dial's reticle. const panelRow = scopeRow; const panelCat = scopeCat; const whyFeelsLike = computeWhyFeelsLike(panelRow ?? null, UTCI_ENVIRONMENTS[utciEnv]); // Synthesize a storm overlay (lightning) when the simulated hour is wet; // outside playback keep the real active event. const scopeEvent = simRow ? ((simRow.precip ?? 0) >= 4 ? { id: 'storm' } : null) : lensEvent; // Local clock label for the playback button / timeline. The window is a ROLLING // 24h from now so it may cross midnight — prefix weekday ("Wed 14:30"). const simClock = (simMs != null && scopeDt) ? (() => { const d = new Date(scopeDt.getTime() + utcOffsetMs); const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }); return `${wd} ${d.toISOString().slice(11, 16)}`; })() : null; const staleThreshMs = isPro ? 15 * 60 * 1000 : 30 * 60 * 1000; const isStale = fetchedAt ? (now - fetchedAt) > staleThreshMs : false; const glanceSummary = computeGlanceSummary( days[selectedDay]?.rows ?? [], activeProfile, outdoorsVariant, skinType, visibleCols, vehicleSpeed, // Farming uses these for seasonal sow/harvest advice (week's dates + weather). activeProfile === 'farming' ? days : null, location?.lat, // Climate normals + selected day's date drive the "vs seasonal average" row. normals, days[selectedDay]?.key, ); // Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026". const glanceDate = (() => { const key = days[selectedDay]?.key; if (!key) return ''; const d = new Date(key + 'T00:00Z'); const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }); const day = d.getUTCDate(); const mon = d.toLocaleDateString('en-GB', { month: 'long', timeZone: 'UTC' }); const yr = d.getUTCFullYear(); const ord = (n => { const s = ['th', 'st', 'nd', 'rd'], v = n % 100; return s[(v - 20) % 10] || s[v] || s[0]; })(day); return `${wd} ${day}${ord} ${mon} ${yr}`; })(); // Pro: export the selected day's full hourly data as a styled spreadsheet. const handleExportDay = () => { exportDayXls(visible, { locationName: location?.name ?? 'Unknown', dateLabel: glanceDate, skinType, }); }; // Day's events surfaced in the "Day at a glance" box. Reuses the same // row shape as glanceSummary items: { icon, label, value, alert }. const hhmm = (iso) => { if (!iso) return ''; const h = parseInt(iso.slice(11, 13), 10); const m = iso.slice(14, 16); const period = h < 12 ? 'am' : 'pm'; const h12 = h % 12 || 12; return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`; }; const hhmmEnd = (iso) => { if (!iso) return ''; const h = parseInt(iso.slice(11, 13), 10) + 1; const m = iso.slice(14, 16); const period = (h % 24) < 12 ? 'am' : 'pm'; const h12 = h % 12 || 12; return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`; }; const eventGlanceItems = (selectedDayEvents ?? []) .filter(ev => ev.type !== 'promo') .map(ev => ({ icon: ev.emoji, label: ev.title, value: ev.isoRange ? (ev.isoRange[0] === ev.isoRange[1] ? hhmm(ev.isoRange[0]) : `${hhmm(ev.isoRange[0])} – ${hhmmEnd(ev.isoRange[1])}`) : (ev.nightOnly ? 'Overnight' : 'All day'), alert: false, })); // ─── 4. JSX RETURN ─────────────────────────────────────────────────── // Everything below is the actual page markup, written as one big HTM // template. Search tips: // • "utci-header" — the top section (title + dial + search) // • "utci-day-tabs" — the 14 day buttons with band colours // • "col-toggles" — the column-customisation row (Pro only) // • "utci-table" — the hourly table itself // • "utci-legend" — the thermal-stress band legend // • "utci-about" — the explainer paragraphs at the bottom // • "utci-footer" — the "reading the table" note return html`
${activeEvents.length > 0 && (() => { const renderSlide = (idx, isOutgoing) => { const ev = activeEvents[idx] || activeEvents[0]; const fmtDate = (iso) => iso ? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }) : null; const startFmt = fmtDate(ev.start); const peakFmt = fmtDate(ev.peak); const endFmt = fmtDate(ev.end); const showDates = startFmt && endFmt; const dateLine = showDates ? (startFmt === endFmt ? peakFmt ? `Peak: ${peakFmt}` : `Date: ${startFmt}` : peakFmt && peakFmt !== startFmt && peakFmt !== endFmt ? `Active ${startFmt} – ${endFmt} · Peak: ${peakFmt}` : `Active ${startFmt} – ${endFmt}`) : null; return html`
dismissBanner(ev.id)} role=${isOutgoing ? undefined : 'button'} title=${isOutgoing ? undefined : 'Click to dismiss'} > ${ev.emoji}
${ev.title}
${ev.message}
${dateLine && html`
${dateLine}
`} ${activeEvents.length > 1 && html`
e.stopPropagation()}> ${activeEvents.map((_, i) => html` { e.stopPropagation(); bannerSlideTo(i); }} style=${{ background: ev.textColor }} /> `)}
`}
${!isOutgoing && html``}
`; }; return html`
${bannerTransition === 'crossfading' && bannerPrevIndex !== null ? renderSlide(bannerPrevIndex, true) : null} ${renderSlide(bannerIndex, false)}
`; })()}

SunScope

See the world the way your skin does.
<${ScopeReticle} value=${scopeRow?.utciAdj ?? null} cat=${scopeCat} loading=${loading} elev=${scopeElev} dt=${scopeDt} glob=${scopeRow?.glob ?? 0} activeEvent=${scopeEvent} utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null} cc=${scopeRow?.cc ?? null} ccLow=${scopeRow?.ccLow ?? null} ccMid=${scopeRow?.ccMid ?? null} ccHigh=${scopeRow?.ccHigh ?? null} precip=${scopeRow?.precip ?? 0} snow=${scopeRow?.snow ?? 0} visKm=${scopeRow?.visKm ?? null} env=${utciEnv} wind=${scopeRow?.va ?? 0} gust=${scopeRow?.gust ?? 0} wd=${scopeRow?.wd ?? null} /> ${hourlyRows.length > 0 && html`
<${DayTimeline} windowStart=${windowStart} windowEnd=${windowEnd} simMs=${simMs} setSimMs=${setSimMs} hourlyRows=${hourlyRows} utcOffsetMs=${utcOffsetMs} />
`}
${forecast && panelRow && whyFeelsLike && html`
Why It Feels ${panelCat && html`${panelCat.label}`}
${(() => { const _wflItems = [ { label: 'Air temperature', icon: '🌡', value: panelRow.Ta, isBase: true }, { label: 'Sun and sky', icon: '☀', value: whyFeelsLike.sunAndSky }, { label: 'Wind', icon: '🌬', value: whyFeelsLike.wind }, { label: 'Humidity', icon: '💧', value: whyFeelsLike.humidity }, ...(whyFeelsLike.environment !== 0 ? [ { label: UTCI_ENVIRONMENTS[utciEnv]?.label ?? 'Environment', icon: '🌍', value: whyFeelsLike.environment } ] : []), ...(whyFeelsLike.precipitation !== 0 ? [ { label: 'Precipitation', icon: '🌧', value: whyFeelsLike.precipitation } ] : []), ]; const _wflDeltaItems = _wflItems.filter(i => !i.isBase); const _wflMax = Math.max(..._wflDeltaItems.map(i => Math.abs(i.value)), 0.1); return _wflItems.map(({ label, icon, value, isBase }) => html`
${icon} ${label} ${!isBase && html` = 0 ? 'bar-pos' : 'bar-neg')} style=${{ width: `${Math.round(Math.abs(value) / _wflMax * 100)}%` }}> `} = 0 ? 'delta-pos' : 'delta-neg'}`}> ${isBase ? '' : value >= 0 ? '+' : ''}${value.toFixed(1)}°
`); })()}
`}
${error && html`
⚠ ${error}
`} ${loading && !error && html`
Acquiring forecast data…
`} ${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`} ${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`} ${forecast && days.length > 0 && html`<${DayTabs} days=${days} selectedDay=${selectedDay} setSelectedDay=${setSelectedDay} isPro=${isPro} openRestore=${openRestore} proPromptDay=${proPromptDay} setProPromptDay=${setProPromptDay} proPromptSource=${proPromptSource} setProPromptSource=${setProPromptSource} activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols} visibleCols=${visibleCols} activityOptions=${activityOptions} placeOptions=${placeOptions} workOptions=${workOptions} activityValue=${activityValue} activityLabel=${activityLabel} placeValue=${placeValue} placeLabel=${placeLabel} workValue=${workValue} workLabel=${workLabel} outdoorsVariant=${outdoorsVariant} setOutdoorsVariantAndSave=${setOutdoorsVariantAndSave} setActiveProfile=${setActiveProfile} setVisibleCols=${setVisibleCols} setIndoorMode=${setIndoorMode} setIndoorManaged=${setIndoorManaged} setBuildingType=${setBuildingType} dayTabsRef=${dayTabsRef} canScrollLeft=${canScrollLeft} canScrollRight=${canScrollRight} scrollDayTabs=${scrollDayTabs} />`}
View: Hours: ${[1, 2, 3, 4].map(n => html``)} ${forecastView === 'simple' && (() => { const showSolar = visibleCols.utciP; const showVehicle = visibleCols.vehicleT; const showIndoor = visibleCols.indoorT || visibleCols.managedT; if (!showSolar && !showVehicle && !showIndoor) return null; const solarOn = simpleTemp === 'utciAdj'; const vehicleOn = simpleTemp === 'vehicleT'; const indoorOn = simpleTemp === 'indoorT' || simpleTemp === 'managedT'; return html` ${showSolar && html` <${CustomSelect} value=${utciEnv} isOn=${solarOn} grpClass="grp-felt" hideLabel="Solar Model" hidingLabel="Solar Model" groupedLeft=${true} isLastChild=${true} options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ value: k, label: v.label }))} onChange=${v => { if (v === 'off') setSimpleTemp(showVehicle ? 'vehicleT' : showIndoor ? 'indoorT' : 'utciAdj'); else { setUtciEnv(v); setSimpleTemp('utciAdj'); } }} /> `} ${showVehicle && html` <${CustomSelect} value=${vehicleType} isOn=${vehicleOn} grpClass="grp-felt" hideLabel="Vehicle" hidingLabel="Vehicle" groupedLeft=${true} isLastChild=${!vehicleOn} options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({ value: k, label: v.name }))} onChange=${v => { if (v === 'off') setSimpleTemp(showSolar ? 'utciAdj' : showIndoor ? 'indoorT' : 'vehicleT'); else { setVehicleType(v); setSimpleTemp('vehicleT'); } }} /> ${vehicleOn && html` <${CustomSelect} value=${vehicleSpeed} isOn=${true} noHide=${true} grpClass="grp-felt" buttonLabel=${(VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).name} groupedLeft=${true} isLastChild=${false} options=${Object.entries(VEHICLE_SPEEDS).map(([k, v]) => ({ value: k, label: v.name }))} onChange=${v => setVehicleSpeed(v)} /> <${VentPill} checked=${vehicleVent} onChange=${() => setVehicleVent(v => !v)} grpClass="grp-felt" label="Ventilation" title="Open windows significantly reduce cabin heat build-up. Speed sets cabin airflow from forward motion." />`} `} ${showIndoor && html` <${CustomSelect} value=${buildingType} isOn=${indoorOn} grpClass="grp-felt" hideLabel="Indoors" hidingLabel="Indoors" groupedLeft=${true} isLastChild=${!indoorOn} options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({ value: k, label: v.name }))} onChange=${v => { if (v === 'off') setSimpleTemp(showSolar ? 'utciAdj' : showVehicle ? 'vehicleT' : 'indoorT'); else { setBuildingType(v); setIndoorMode('on'); setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT'); } }} /> ${indoorOn && html` <${VentPill} checked=${indoorManaged} onChange=${() => { const next = !indoorManaged; setIndoorManaged(next); setSimpleTemp(next ? 'managedT' : 'indoorT'); }} grpClass="grp-felt" label="Managed" title="Curtains closed by day, windows open when cooler outside" />`} `} `; })()} Columns:
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP']) && html` <${CustomSelect} value=${utciEnv} isOn=${visibleCols.utciP} grpClass="grp-felt" hideLabel="SunSoak" hidingLabel="Hide SunSoak" groupedLeft=${true} isLastChild=${true} options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ value: k, label: v.label, }))} onChange=${(v) => { if (v === 'off') { setVisibleCols(prev => ({ ...prev, utciP: false })); } else { setUtciEnv(v); setVisibleCols(prev => ({ ...prev, utciP: true })); } }} /> `} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']) && html` <${CustomSelect} value=${vehicleType} isOn=${visibleCols.vehicleT} grpClass="grp-felt" hideLabel="Vehicle" hidingLabel="Hide Vehicle" groupedLeft=${true} isLastChild=${!visibleCols.vehicleT} options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({ value: k, label: v.name, }))} onChange=${(v) => { if (v === 'off') { setVisibleCols(prev => ({ ...prev, vehicleT: false })); setVehicleVent(false); } else { setVehicleType(v); setVisibleCols(prev => ({ ...prev, vehicleT: true })); } }} /> ${visibleCols.vehicleT && html` <${CustomSelect} value=${vehicleSpeed} isOn=${true} noHide=${true} grpClass="grp-felt" buttonLabel=${(VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).name} groupedLeft=${true} isLastChild=${false} options=${Object.entries(VEHICLE_SPEEDS).map(([k, v]) => ({ value: k, label: v.name }))} onChange=${(v) => setVehicleSpeed(v)} /> <${VentPill} checked=${vehicleVent} onChange=${() => setVehicleVent(v => !v)} grpClass="grp-felt" label="Ventilation" title="Ventilation — open windows significantly reduce cabin heat build-up. Speed sets cabin airflow from forward motion." />`} `} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html` <${CustomSelect} value=${buildingType} isOn=${indoorMode === 'on'} grpClass="grp-felt" hideLabel="Indoors" hidingLabel="Hide Indoors" groupedLeft=${true} isLastChild=${indoorMode !== 'on'} options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({ value: k, label: v.name, }))} onChange=${(v) => { if (v === 'off') { setIndoorMode('off'); setIndoorManaged(false); } else { setBuildingType(v); setIndoorMode('on'); } }} /> ${indoorMode === 'on' && html` <${VentPill} checked=${indoorManaged} onChange=${() => setIndoorManaged(v => !v)} grpClass="grp-felt" label="Managed" title="Managed: curtains closed by day, windows open when cooler outside" />`} `} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['furSurfaceT']) : activeCols['furSurfaceT']) && html` <${CustomSelect} value=${furColor} isOn=${visibleCols.furSurfaceT} grpClass="grp-surface" hideLabel="Fur Colour" hidingLabel="Hide Fur Colour" groupedLeft=${true} isLastChild=${true} options=${Object.entries(FUR_COLORS).map(([k, v]) => ({ value: k, label: v.name }))} onChange=${(v) => { if (v === 'off') { setVisibleCols(prev => ({ ...prev, furSurfaceT: false })); } else { setFurColor(v); setVisibleCols(prev => ({ ...prev, furSurfaceT: true })); } }} /> `} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pawT']) : activeCols['pawT']) && html``} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['petShadeT']) : activeCols['petShadeT']) && html``} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['petHomeT']) : activeCols['petHomeT']) && html``} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['burn']) : activeCols['burn']) && html` <${CustomSelect} value=${skinType} isOn=${visibleCols.burn} grpClass="grp-felt" hideLabel="Burn" hidingLabel="Hide Burn" options=${Object.entries(SKIN_TYPES).map(([k, v]) => ({ value: k, label: v.name.split(' · ')[1] + ' skin', }))} onChange=${(v) => { if (v === 'off') { setVisibleCols(prev => ({ ...prev, burn: false })); } else { setSkinType(v); setVisibleCols(prev => ({ ...prev, burn: true })); } }} />`} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utci']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['delta']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['tmrt']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['concreteT']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['soilT']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['soilT6']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['soilM']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['shadeT']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['air']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['rh']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['dew']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['precip']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['precipProb']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['lightning']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['cloud']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vis']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['wind']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['dir']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['aqi']) && html``} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pollen']) : activeCols['pollen']) && html`<${CustomSelect} value=${pollenType} isOn=${visibleCols.pollen} grpClass="grp-airqual" hideLabel="Pollen" hidingLabel="Hide Pollen" options=${Object.entries(POLLEN_TYPES).flatMap(([k, v], i) => [ { value: k, label: v.name }, ...(i === 0 ? [{ value: '_div', divider: true }] : []), ])} onChange=${(v) => { if (v === 'off') { setVisibleCols(prev => ({ ...prev, pollen: false })); } else { setPollenTypeAndSave(v); setVisibleCols(prev => ({ ...prev, pollen: true })); } }} />`} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvA']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvB']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['sun']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['direct']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['diffuse']) && html``}
${tableRows.map(r => { const dispTemp = r[simpleTemp] ?? r.utciAdj; const cat = utciCategory(dispTemp); const h24s = parseInt(r.iso.slice(11, 13), 10); const localHHMMs = h24s === 0 ? '12am' : h24s < 12 ? `${h24s}am` : h24s === 12 ? '12pm' : `${h24s - 12}pm`; const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO; const domIcon = (() => { if (r.precipProb > 20 && (r.precip > 0 || r.snow > 0)) return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} filled=${true} />`; if (r.cloudCat && r.cloudCat !== 'clear') return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} />`; return null; })(); const windMph = Math.round((r.gust ?? r.va) * 2.237); return html`
${localHHMMs}
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${42} /> ${domIcon && html`
${domIcon}
`}
${r.precipProb}% ${windMph}mph ${r.compass ? r.compass.label : '—'}
${Math.round(dispTemp)}°
${cat.label}
`; })}
${(() => { if (!tableRows.length) return null; const fscN = tableRows.length; const fscSvgW = 1000, fscSvgH = 80; const fscCardW = fscSvgW / fscN; // Use full 1-hour data for the smooth curve, tableRows for connectors/gradient const fscSrc = visible.length > fscN ? visible : tableRows; // Fixed scale: bottom = Freezing band bottom (−10) − 10, top = Danger start (44) + 10 const fscMin = -15, fscMax = 45; const toY = t => fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * fscSvgH; const getT = r => r[simpleTemp] ?? r.utciAdj; // 1-hour points for the smooth curve — evenly spread across SVG width const fscAllPts = fscSrc.map((r, j) => ({ x: (j + 0.5) * fscSvgW / fscSrc.length, y: toY(getT(r)), })); // Connector points — x centred under card column, y at the exact point // the 1-hour bezier curve passes through at that x. // For a bucket of size n starting at visible index jFirst, the card // centre x lands at the bezier midpoint between visible[jFirst+(n-1)/2 floor] // and visible[jFirst+(n-1)/2 ceil], so y = lerp of those two neighbours. let fscVj = 0; const fscPts = tableRows.map((r, i) => { const bucketLen = r.isoHours ? r.isoHours.length : 1; const jFirst = fscVj; fscVj += bucketLen; const x = (i + 0.5) * fscCardW; const ctr = (bucketLen - 1) / 2; const jLow = Math.min(jFirst + Math.floor(ctr), fscSrc.length - 1); const jHigh = Math.min(jFirst + Math.ceil(ctr), fscSrc.length - 1); const frac = ctr - Math.floor(ctr); const tCtr = getT(fscSrc[jLow]) * (1 - frac) + getT(fscSrc[jHigh]) * frac; return { x, y: toY(tCtr) }; }); const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO); // Extended fill points (edge-anchored) for the gradient area const fscFillPts = [{ x: 0, y: fscAllPts[0].y }, ...fscAllPts, { x: fscSvgW, y: fscAllPts[fscAllPts.length - 1].y }]; let fscFillLine = `M ${fscFillPts[0].x},${fscFillPts[0].y}`; for (let i = 1; i < fscFillPts.length; i++) { const p0 = fscFillPts[i - 1], p1 = fscFillPts[i]; const cpx = (p0.x + p1.x) / 2; fscFillLine += ` C ${cpx},${p0.y} ${cpx},${p1.y} ${p1.x},${p1.y}`; } const fscFill = fscFillLine + ` L ${fscSvgW},${fscSvgH} L 0,${fscSvgH} Z`; return html` ${fscSrc.map((r, j) => { const rgb = airTempRgbStrong(getT(r)) || [200, 200, 200]; const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; return html``; })} ${fscSrc.map((r, j) => { const rgb = airTempRgbVeryStrong(getT(r)) || [200, 200, 200]; const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; return html``; })} ${[[0, '#4a90c4'], [20, '#4a9e6a']].map(([t, color]) => { const gy = toY(t); return html` `; })} ${fscPts.map((pt, i) => { const isNow = fscNowFlags[i]; return html` `; })} `; })()}
${groupLabelRow()} ${visibleCols.utciP && html``} ${visibleCols.vehicleT && html``} ${indoorMode === 'on' && !indoorManaged && html``} ${indoorMode === 'on' && indoorManaged && html``} ${visibleCols.petHomeT && html``} ${visibleCols.burn && html``} ${visibleCols.utci && html``} ${visibleCols.delta && html``} ${visibleCols.tmrt && html``} ${visibleCols.concreteT && html``} ${visibleCols.furSurfaceT && html``} ${visibleCols.pawT && html``} ${visibleCols.soilT && html``} ${visibleCols.soilT6 && html``} ${visibleCols.soilM && html``} ${visibleCols.shadeT && html``} ${visibleCols.petShadeT && html``} ${visibleCols.air && html``} ${visibleCols.rh && html``} ${visibleCols.dew && html``} ${visibleCols.precip && html``} ${visibleCols.precipProb && html``} ${visibleCols.lightning && html``} ${visibleCols.cloud && html``} ${visibleCols.vis && html``} ${visibleCols.wind && html``} ${visibleCols.dir && html``} ${visibleCols.aqi && html``} ${visibleCols.pollen && html``} ${visibleCols.uvA && html``} ${visibleCols.uvB && html``} ${visibleCols.sun && html``} ${visibleCols.direct && html``} ${visibleCols.diffuse && html``}
handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}> Hour e.stopPropagation()}> ${[1, 2, 3, 4].map(n => html` `)} handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>SunSoak °C felt${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ? html`${UTCI_ENVIRONMENTS[utciEnv].shortLabel}` : null} handleThClick('vehicleT', e)} onMouseEnter=${(e) => handleThEnter('vehicleT', e)} onMouseLeave=${handleThLeave}>Vehicle °C peak handleThClick('indoorT', e)} onMouseEnter=${(e) => handleThEnter('indoorT', e)} onMouseLeave=${handleThLeave}>Indoors °C est. handleThClick('managedT', e)} onMouseEnter=${(e) => handleThEnter('managedT', e)} onMouseLeave=${handleThLeave}>Managed °C est. handleThClick('petHomeT', e)} onMouseEnter=${(e) => handleThEnter('petHomeT', e)} onMouseLeave=${handleThLeave}>Pet Home °C est. handleThClick('burn', e)} onMouseEnter=${(e) => handleThEnter('burn', e)} onMouseLeave=${handleThLeave}>Burn to MED handleThClick('utci', e)} onMouseEnter=${(e) => handleThEnter('utci', e)} onMouseLeave=${handleThLeave}>UTCI °C felt handleThClick('delta', e)} onMouseEnter=${(e) => handleThEnter('delta', e)} onMouseLeave=${handleThLeave}>Δ UTCI−Air handleThClick('tmrt', e)} onMouseEnter=${(e) => handleThEnter('tmrt', e)} onMouseLeave=${handleThLeave}>Tmrt °C handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete °C surface handleThClick('furSurfaceT', e)} onMouseEnter=${(e) => handleThEnter('furSurfaceT', e)} onMouseLeave=${handleThLeave}>Fur °C surface handleThClick('pawT', e)} onMouseEnter=${(e) => handleThEnter('pawT', e)} onMouseLeave=${handleThLeave}>Paw °C surface handleThClick('soilT', e)} onMouseEnter=${(e) => handleThEnter('soilT', e)} onMouseLeave=${handleThLeave}>Soil °C surface handleThClick('soilT6', e)} onMouseEnter=${(e) => handleThEnter('soilT6', e)} onMouseLeave=${handleThLeave}>Soil 6cm °C root handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist % handleThClick('shadeT', e)} onMouseEnter=${(e) => handleThEnter('shadeT', e)} onMouseLeave=${handleThLeave}>Shade °C handleThClick('petShadeT', e)} onMouseEnter=${(e) => handleThEnter('petShadeT', e)} onMouseLeave=${handleThLeave}>Pet Shade °C handleThClick('air', e)} onMouseEnter=${(e) => handleThEnter('air', e)} onMouseLeave=${handleThLeave}>Air °C handleThClick('rh', e)} onMouseEnter=${(e) => handleThEnter('rh', e)} onMouseLeave=${handleThLeave}>RH % handleThClick('dew', e)} onMouseEnter=${(e) => handleThEnter('dew', e)} onMouseLeave=${handleThLeave}>Dew °C handleThClick('precip', e)} onMouseEnter=${(e) => handleThEnter('precip', e)} onMouseLeave=${handleThLeave}>Pcpt mm/h handleThClick('precipProb', e)} onMouseEnter=${(e) => handleThEnter('precipProb', e)} onMouseLeave=${handleThLeave}>Rain % handleThClick('lightning', e)} onMouseEnter=${(e) => handleThEnter('lightning', e)} onMouseLeave=${handleThLeave}>⚡ LPI handleThClick('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud % handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Vis km handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind mph (gust) handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir - handleThClick('aqi', e)} onMouseEnter=${(e) => handleThEnter('aqi', e)} onMouseLeave=${handleThLeave}>AQI EU idx handleThClick('pollen', e)} onMouseEnter=${(e) => handleThEnter('pollen', e)} onMouseLeave=${handleThLeave}>${pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen','') ?? 'Pollen'} grains/m³ handleThClick('uvA', e)} onMouseEnter=${(e) => handleThEnter('uvA', e)} onMouseLeave=${handleThLeave}>UV-A est. idx handleThClick('uvB', e)} onMouseEnter=${(e) => handleThEnter('uvB', e)} onMouseLeave=${handleThLeave}>UV-B est. idx handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun elev° handleThClick('direct', e)} onMouseEnter=${(e) => handleThEnter('direct', e)} onMouseLeave=${handleThLeave}>Direct W/m² handleThClick('diffuse', e)} onMouseEnter=${(e) => handleThEnter('diffuse', e)} onMouseLeave=${handleThLeave}>Diffuse W/m²
${tableRows.map((r, rowIdx) => { const rPrev = tableRows[rowIdx - 1]; const rNext = tableRows[rowIdx + 1]; const cat = utciCategory(r.utci); const isNight = r.elev < 0; const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO; const delta = r.utci - r.Ta; const adjCat = utciCategory(r.utciAdj); // Converts a band hex colour to rgba at the given alpha — used to // tint the UTCI and UTCI+P cells directly from the band colour. const hexToRgba = (hex, a) => { const h = hex.replace('#', ''); const r = parseInt(h.slice(0,2),16); const g = parseInt(h.slice(2,4),16); const b = parseInt(h.slice(4,6),16); return `rgba(${r},${g},${b},${a})`; }; const fmt = (v, dp = 1) => v == null ? '—' : (showDecimals ? v.toFixed(dp) : String(Math.round(v))); const u = (unit) => showUnits ? unit : ''; // Returns a background tint based on temperature value - all share the same // Continuous temperature colour scale — used for all temp columns. // airTempRgb(t) returns the whiteblended [r,g,b] for a temperature. // airTempBg(t, tPrev, tNext) returns a top→bottom gradient that // flows from the previous row's colour through this row's colour to // the next row's colour, making the whole column one seamless heatmap. // airTempRgb and TEMP_STOPS hoisted to component scope above. const toRgb = (rgb) => rgb ? `rgb(${rgb[0]},${rgb[1]},${rgb[2]})` : 'transparent'; const airTempBg = (t, tPrev, tNext) => { if (t == null) return 'transparent'; // Top colour = midpoint temp between prev row and this row. // Bottom colour = midpoint temp between this row and next row. // This guarantees the boundary colour is identical on both sides // of every cell edge, giving a perfectly seamless column gradient. const tTop = tPrev != null ? (tPrev + t) / 2 : t; const tBot = tNext != null ? (t + tNext) / 2 : t; return `linear-gradient(to bottom, ${toRgb(airTempRgb(tTop))} 0%, ${toRgb(airTempRgb(tBot))} 100%)`; }; const airTempFontColor = () => '#1a1a1a'; // Returns any extra band styles (fontWeight, textShadow) for a given temp. const bandExtras = (t) => { const band = utciCategory(t); if (!band) return {}; return { ...(band.fontWeight ? { fontWeight: band.fontWeight } : {}), ...(band.textShadow ? { textShadow: band.textShadow } : {}), }; }; const scaleBg = (v, min, max, rgb, maxAlpha = 0.14) => { if (v == null || isNaN(v)) return 'transparent'; if (v <= min) return 'transparent'; const x = Math.max(0, Math.min(1, (v - min) / (max - min))); const a = 0.035 + x * maxAlpha; return `rgba(${rgb},${a.toFixed(3)})`; }; const soilMoistureBg = (v) => { if (v == null || isNaN(v)) return { bg: 'transparent', fg: '#555' }; const pct = v * 100; // stops: [pct, r, g, b] const stops = [ [0, 212, 180, 131], // sandy tan – bone dry [10, 160, 120, 64], // mid brown – dry [22, 138, 122, 80], // olive-brown – damp/firm [32, 200, 160, 64], // amber – soft, caution [38, 58, 138, 92], // green – wet [50, 42, 106, 144], // blue – saturated ]; let s0 = stops[0], s1 = stops[stops.length - 1]; for (let i = 0; i < stops.length - 1; i++) { if (pct >= stops[i][0] && pct <= stops[i+1][0]) { s0 = stops[i]; s1 = stops[i+1]; break; } } const t = s1[0] === s0[0] ? 1 : Math.max(0, Math.min(1, (pct - s0[0]) / (s1[0] - s0[0]))); const r = Math.round(s0[1] + t * (s1[1] - s0[1])); const g = Math.round(s0[2] + t * (s1[2] - s0[2])); const b = Math.round(s0[3] + t * (s1[3] - s0[3])); const lum = 0.299*r + 0.587*g + 0.114*b; return { bg: `rgba(${r},${g},${b},0.18)`, fg: '#3a2a10' }; }; const deltaBg = (v) => { if (v == null || isNaN(v)) return 'transparent'; if (Math.abs(v) < 1) return 'transparent'; const x = Math.min(1, Math.abs(v) / 12); const a = 0.035 + x * 0.13; return v > 0 ? `rgba(230,140,50,${a.toFixed(3)})` : `rgba(80,135,210,${a.toFixed(3)})`; }; const burnBg = (mins, uv) => { if (!isFinite(mins) || uv <= 0) return 'transparent'; if (mins >= 240) return 'transparent'; const x = Math.max(0, Math.min(1, (240 - mins) / 220)); return `rgba(210,70,50,${(0.04 + x * 0.15).toFixed(3)})`; }; // r.iso is the local wall-clock string from the API — slice it directly. const h24 = parseInt(r.iso.slice(11, 13), 10); const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`; const burnMins = sunburnMinutes(r.uv, skinType); return html` ${visibleCols.utciP && html` `} ${visibleCols.vehicleT && html` `} ${indoorMode === 'on' && !indoorManaged && html` `} ${indoorMode === 'on' && indoorManaged && html` `} ${visibleCols.petHomeT && html` `} ${visibleCols.burn && html` `} ${visibleCols.utci && html` `} ${visibleCols.delta && html` `} ${visibleCols.tmrt && html``} ${visibleCols.concreteT && html` `} ${visibleCols.furSurfaceT && html` `} ${visibleCols.pawT && (() => { const risk = pawBurnRiskLabel(r.concreteT); return html``; })()} ${visibleCols.soilT && html` `} ${visibleCols.soilT6 && html` `} ${visibleCols.soilM && html` `} ${visibleCols.shadeT && html``} ${visibleCols.petShadeT && html` `} ${visibleCols.air && html``} ${visibleCols.rh && html``} ${visibleCols.dew && html``} ${visibleCols.precip && html` `} ${visibleCols.precipProb && html` `} ${visibleCols.lightning && (() => { const lpi = r.lightning ?? 0; const bg = lpi >= 25 ? 'rgba(255,180,0,0.25)' : lpi >= 5 ? 'rgba(255,210,0,0.15)' : 'transparent'; const color = lpi >= 25 ? '#7a4800' : lpi >= 5 ? '#6a5800' : '#7a8a90'; return html``; })()} ${visibleCols.cloud && html``} ${visibleCols.vis && (() => { const v = r.visKm; const bg = v == null ? 'transparent' : v < 1 ? 'rgba(180,80,80,0.18)' : v < 4 ? 'rgba(210,140,50,0.15)' : v < 10 ? 'rgba(200,190,80,0.12)' : 'transparent'; const color = v != null && v < 4 ? '#8a3a1a' : '#4a3218'; return html``; })()} ${visibleCols.wind && html``} ${visibleCols.dir && html``} ${visibleCols.aqi && (() => { const v = r.aqi == null ? null : Math.round(r.aqi); const bg = v == null ? 'transparent' : v < 20 ? 'rgba(80,180,100,0.15)' : v < 40 ? 'rgba(140,200,100,0.13)' : v < 60 ? 'rgba(220,200,60,0.15)' : v < 80 ? 'rgba(220,130,50,0.18)' : v < 100 ? 'rgba(200,70,50,0.18)' : 'rgba(160,30,100,0.20)'; const color = v != null && v >= 60 ? '#7a2010' : v != null && v >= 40 ? '#7a4a10' : '#2a4a20'; const label = v == null ? '—' : v < 20 ? `${v} Good` : v < 40 ? `${v} Fair` : v < 60 ? `${v} Mod` : v < 80 ? `${v} Poor` : v < 100 ? `${v} V.Poor` : `${v} Hazard`; return html``; })()} ${visibleCols.pollen && (() => { const pollenMap = { all_pollen: [r.grassPollen, r.birchPollen, r.alderPollen, r.mugwortPollen, r.olivePollen, r.ragweedPollen].reduce((s, x) => x != null ? s + x : s, null), grass_pollen: r.grassPollen, birch_pollen: r.birchPollen, alder_pollen: r.alderPollen, mugwort_pollen: r.mugwortPollen, olive_pollen: r.olivePollen, ragweed_pollen: r.ragweedPollen, }; const v = pollenMap[pollenType] ?? null; const bg = v == null ? 'transparent' : v < 10 ? 'transparent' : v < 50 ? 'rgba(180,200,80,0.13)' : v < 200 ? 'rgba(210,150,50,0.16)' : 'rgba(200,70,50,0.18)'; const color = v != null && v >= 200 ? '#8a2010' : v != null && v >= 50 ? '#7a4a10' : '#4a3218'; const label = v == null ? '—' : v < 10 ? `${Math.round(v)} Low` : v < 50 ? `${Math.round(v)} Mod` : v < 200 ? `${Math.round(v)} High` : `${Math.round(v)} V.High`; return html``; })()} ${visibleCols.uvA && html` `} ${visibleCols.uvB && html` `} ${visibleCols.sun && html``} ${visibleCols.direct && html``} ${visibleCols.diffuse && html``} `; })}
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} /> ${localHHMM} ${(() => { const rowEvents = getCellTagEvents(selectedDayEvents, r); return rowEvents.map((ev, idx) => html` handleEventTagClick(rowEvents, idx, e)} onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)} onMouseLeave=${handleEventTagLeave} role="button" tabIndex="0" aria-label=${ev.title} >${ev.emoji} `); })()} ${fmt(r.utciAdj)}${u('°C')} ${fmt(r.vehicleT)}${u('°C')} ${fmt(r.indoorT)}${u('°C')} ${fmt(r.managedT)}${u('°C')} ${fmt(r.indoorT)}${u('°C')} 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins, r.uv) }}> ${burnLabel(burnMins)} ${fmt(r.utci)}${u('°C')} 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218', background: deltaBg(delta), }}> ${delta > 0 ? '+' : ''}${fmt(delta)}${u('°C')} ${fmt(r.Tmrt)}${u('°C')} ${fmt(r.concreteT)}${u('°C')} ${fmt(r.furSurfaceT)}${u('°C')} ${fmt(r.concreteT)}${u('°C')} ${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}${r.soilT6 != null ? fmt(r.soilT6) + u('°C') : '—'} { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'} ${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'} ${fmt(r.Ta)}${u('°C')}${Math.round(r.RH)}${u('%')}${fmt(r.dew)}${u('°C')} 0 ? scaleBg(r.snow, 0, 4, '90,140,210') : scaleBg(r.precip, 0, 8, '70,145,200') }}> <${PrecipIcon} precip=${r.precipProb > 0 ? r.precip : 0} snow=${r.precipProb > 0 ? r.snow : 0} size=${28} /> 0 ? '#2a5fa8' : r.precip > 0 ? '#2a6a90' : '#7a5c30' }}> ${r.precipProb > 0 ? (r.snow > 0 ? fmt(r.snow) + u('cm') : r.precip > 0 ? fmt(r.precip) + u('mm') : '—') : '—'} = 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90' }}> ${r.precipProb}${u('%')} ${lpi > 0 ? `${Math.round(lpi)}` : ''} <${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} /> ${Math.round(r.cc)}${u('%')} ${v != null ? fmt(v) + u('km') : '—'} ${Math.round(r.va * 2.237)}${u('mph')}${r.gust != null && r.gust > r.va + 0.5 ? (gm => html`= 25 ? 700 : 'normal', opacity: gm >= 25 ? 1 : 0.65, color: gm >= 55 ? '#b81010' : gm >= 40 ? '#d44010' : gm >= 25 ? '#c47a00' : 'inherit' }}>(${Math.round(gm)}${u('mph')})`)(r.gust * 2.237) : ''} <${WindVane} bearing=${r.wd} size=${28} /> ${r.compass.label} = 60 ? 600 : 400 }}>${label}= 50 ? 600 : 400 }}>${label} 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}> ${r.uvA > 0 ? fmt(r.uvA) + u(' idx') : '—'} 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}> ${r.uvB > 0 ? fmt(r.uvB, 2) + u(' idx') : '—'} 0 ? r.elev : null, 0, 70, '225,160,45') }}>${r.elev > 0 ? fmt(r.elev) + u('°') : '—'}${Math.round(r.dir)}${u('W/m²')}${Math.round(r.dif)}${u('W/m²')}
${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0 || visible.length > 0) && html` `}
${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
${COL_DESCRIPTIONS[colPopup.key].title}

${COL_DESCRIPTIONS[colPopup.key].desc}

`} ${eventTagPopup && (() => { const evs = eventTagPopup.events; const ev = evs[evSlideIndex] || evs[0]; return html`
${ev.emoji} ${ev.title}

${ev.message}

${evs.length > 1 && html`
${evs.map((_, i) => html` evSlideTo(i)} /> `)}
`}
`; })()}
Thermal stress bands
${[ { t: -9, label: 'Freezing', value: '< 0°C' }, { t: 1, label: 'Cold', value: '0–10°C' }, { t: 11, label: 'Cool', value: '10–19°C' }, { t: 21, label: 'Comfortable', value: '19–24°C', bold: true }, { t: 29, label: 'Caution', value: '27–32°C' }, { t: 36, label: 'Extreme', value: '32–41°C' }, { t: 47, label: 'Danger', value: '41°C+' }, ].map((b, i) => { const rgb = airTempRgb(b.t) || [200, 200, 200]; const mid = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; const light = `rgb(${rgb.map(c => Math.min(255, Math.round(c + (255-c)*0.30))).join(',')})`; const dark = `rgb(${rgb.map(c => Math.round(c * 0.96)).join(',')})`; const bg = `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`; const lum = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) / 1000; const fg = lum > 165 ? '#1a1a1a' : '#ffffff'; return html` ${b.label} ${b.value} `; })}
${(() => { const upcoming = getUpcomingEvents(location, 3650).slice(0, 4); const formatPeak = (iso) => { const d = new Date(iso + 'T00:00Z'); return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }); }; const countdownLabel = (days) => { if (days === 0) return 'Tonight!'; if (days === 1) return 'Tomorrow'; if (days < 7) return `In ${days} days`; if (days < 14) return 'Next week'; if (days < 60) return `In ${Math.round(days / 7)} weeks`; return `In ${Math.round(days / 30)} months`; }; return html`
🔭 What's Coming Cosmic events · next 4 events · ${location.name}
${upcoming.length === 0 ? html`
No major cosmic events on the horizon — clear skies ahead.
` : upcoming.map(ev => html`
${ev.emoji}
${ev.title} ${countdownLabel(ev.daysUntil)}
${ev.desc}
${ev.visibilityNote && html` 📍 ${ev.visibilityNote} `}
`) }
`; })()}

What is SunScope?

SunScope is a free hourly weather forecast built around felt temperature, not just air temperature. It uses the Universal Thermal Climate Index (UTCI) — the biometeorological standard used in heat-health warning systems worldwide — as its foundation, combining air temperature, humidity, wind, and solar radiation into a single honest number. Then it goes further. Our SunSoak index layers three extra dimensions on top: a rain and snow penalty so wet, windy days read as cold as they feel; an environment modifier that adjusts the solar load for where you actually are — forest canopy, alpine altitude, lakeside glare, shaded riverbank, desert ground heat; and the full radiant heat absorbed from surrounding surfaces. One number that honestly answers: what will my body actually feel out there?

Beyond SunSoak, SunScope calculates vehicle cabin heat (choose your vehicle type; toggle windows open), indoor temperature (seven building types including office blocks; managed heatwave mode), urban concrete surface temperature, UV index and sunburn time by skin type, and soil temperature and moisture for farming and motorhome use. Switch profiles — Places, Activities, Work — to see the data that matters for your situation, or go Custom and build your own view. Learn more →

Reading the table

SunSoak is the number to watch — it reflects everything your body actually experiences, not just what the thermometer says. A large gap between SunSoak and Air temperature means solar radiation is doing significant work on your body. On clear sunny days that gap can exceed 10 °C even at modest air temperatures. The environment modifier in the SunSoak dropdown adjusts the solar load for your surroundings — switch it to match where you are for the most accurate reading. ${' '} How it works →

${isPro && html` `}
`; }