// ════════════════════════════════════════════════════════════════════════ // 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, petCategory, SKIN_TYPES, sunburnMinutes, burnLabel, VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES, FUR_COLORS, confidenceBand, moonGlyph, skyFillForElev, } from './utils.js'; import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill, RowStepper } from './components.js'; import { getCellTagEvents, getUpcomingEvents, PRIORITY_WEATHER_IDS } from './events.js'; import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS, FILTER_PROFILES, variantIcons, deriveProfileMain } from './config.js'; import { useAppState } from './hooks/useAppState.js'; import { DayTabs } from './components/DayTabs.js'; import { ConfigPanel } from './components/ConfigPanel.js'; import { WelcomeModal } from './components/WelcomeModal.js'; import { RestoreModal } from './components/RestoreModal.js'; import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay } 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), }; } // Turn a raw fetch failure into something a person can act on. The banner only // ever appears when nothing at all has loaded (see the loadForecast waterfall // in hooks/useForecast.js), so "showing your last saved forecast" is never the // right thing to say here - there isn't one. function friendlyError(msg) { const m = String(msg || ''); if (typeof navigator !== 'undefined' && navigator.onLine === false) { return "You're offline. Reconnect and try again."; } if (/Failed to fetch|NetworkError|Load failed/i.test(m)) { return "Couldn't reach the weather service. Check your connection and try again."; } const status = m.match(/HTTP (\d{3})/); if (status) { const code = Number(status[1]); if (code === 429) return 'Too many requests just now. Give it a minute and try again.'; if (code >= 500) return "The weather service isn't responding. Try again in a moment."; return `The weather service rejected the request (error ${code}).`; } return m; } // ── 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, useMyLocation, locating, locateError, setLocateError, shareForecast, shareState, forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, normals, retry, 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, panelOpen, openPanel, closePanel, 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, } = 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); // ── Event note ─────────────────────────────────────────────────────── // A content-width strip above the nav showing one active event at a time // with its full message, cycling through them with a fade. In flow, so it // pushes the page down rather than covering anything (see .event-note). const [noteIndex, setNoteIndex] = useState(0); const [noteFading, setNoteFading] = useState(false); const NOTE_MS = 8000; const NOTE_FADE_MS = 350; const noteGoTo = (next) => { if (next === noteIndex) return; setNoteFading(true); setTimeout(() => { setNoteIndex(next); setNoteFading(false); }, NOTE_FADE_MS); }; // Keep the index in range if the event list shrinks between forecasts. useEffect(() => { if (noteIndex >= activeEvents.length) setNoteIndex(0); }, [activeEvents.length]); useEffect(() => { if (activeEvents.length <= 1) return; const id = setInterval(() => { setNoteFading(true); setTimeout(() => { setNoteIndex(i => (i + 1) % activeEvents.length); setNoteFading(false); }, NOTE_FADE_MS); }, NOTE_MS); return () => clearInterval(id); }, [activeEvents.length]); const [colTogglesOpen, setColTogglesOpen] = useState(false); // Which column pills are offered in the Columns bar. The bar as a whole is // Extra-only (see the isPro gate around col-toggles-body), so this just asks // "is this column part of the current profile?" — Custom and Show All offer // the lot, every other profile offers what it actually uses. const colOffered = (key) => activeProfile === 'custom' || activeProfile === 'showall' || !!activeCols[key]; 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' || (activeProfile === 'outdoors' && outdoorsVariant === 'driver')) setSimpleTemp('vehicleT'); else if (activeProfile === 'home' || (activeProfile === 'outdoors' && outdoorsVariant === 'office')) setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT'); else if (activeProfile === 'pets') setSimpleTemp('furSurfaceT'); else setSimpleTemp('utciAdj'); if (['alltemps', 'showall', 'custom', 'farming', 'construction', 'market', 'windowcleaning', 'office'].includes(activeProfile)) { setForecastView('table'); } }, [activeProfile, outdoorsVariant]); // 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', 'shadeT', 'vehicleT', 'indoorT', 'managedT', 'burn', 'utci', 'delta', 'tmrt'], surface: ['concreteT', 'soilT', 'soilT6', 'soilM', 'furSurfaceT', 'pawT'], ambient: ['petShadeT', 'petHomeT', '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))); }; // Pet columns (Fur Colour, Pet Shade, Pet Home, Paw) reuse airTempRgb // exactly as-is - identical stops, identical whiteMix blend, identical // per-row top/bottom cell blending (petAirTempBg mirrors airTempBg // below). The only thing that differs is which temperature gets handed // to it: petEquivHumanTemp() remaps a pet reading to "the human felt-temp // this severity is equivalent to" first, using the exact same anchor // pairs PET_BANDS was calibrated against (same tier, same ordinal // position in UTCI_BANDS vs PET_BANDS - see utils.js). So a -2 -C pet // reading (mild "Cold", not "Freezing") gets looked up as if it were a // few degrees warmer on the human scale, and a 46 -C paw reading (mid // "Extreme", not "Danger") looks up around human "Extreme" too - never a // different colour-computation, just a different input to the same one. const PET_TO_HUMAN_TEMP = [ [-28, -20], [-18, -10], [-8, 0], [-3, 5], [2, 10], [7, 15], [11, 19], [25, 24], [32, 27], [40, 32], [52, 41], ]; const petEquivHumanTemp = (t) => { if (t == null) return null; const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const pts = PET_TO_HUMAN_TEMP; const slopeBetween = (a, b) => (b[1] - a[1]) / (b[0] - a[0]); if (t <= pts[0][0]) { const slope = slopeBetween(pts[0], pts[1]); return pts[0][1] + (t - pts[0][0]) * slope; } if (t >= pts[pts.length - 1][0]) { const last = pts[pts.length - 1], prev = pts[pts.length - 2]; const slope = slopeBetween(prev, last); return last[1] + (t - last[0]) * slope; } for (let i = 1; i < pts.length; i++) { const [p0, h0] = pts[i - 1], [p1, h1] = pts[i]; if (t <= p1) { const x = clamp((t - p0) / (p1 - p0), 0, 1); return h0 + (h1 - h0) * x; } } }; const petAirTempRgb = (t, whiteMix = 0.58) => airTempRgb(petEquivHumanTemp(t), whiteMix); // ─── 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; // CAMS air quality runs out well before the 14-day forecast does, so the AQI // and Pollen columns hit a wall partway along the day tabs. Without saying so // the empty cells read as a bug, which is worse than the missing data. const aqBeyond = !!(aqHorizon && days[selectedDay]?.key && days[selectedDay].key > aqHorizon); const aqBeyondNote = aqBeyond ? `Air quality and pollen are only forecast to ${new Date(`${aqHorizon}T00:00:00Z`) .toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}` : null; 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, ); // Week-scoped, so it is kept out of the day-scoped "At a glance" panel and // rendered in its own "The Week Ahead" box beneath it. const weekAhead = computeBestDay(days, activeProfile, outdoorsVariant, nowLocalISO); // 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, })); // ─── 3b. QUICK-VIEW PLOT GEOMETRY ──────────────────────────────────── // Shared by the simple-view cards and the temperature curve beneath them // so the two can never drift apart. // // The curve is drawn from the full-resolution hourly rows (all 24 hours, // never resampled), but each card covers a bucket of `tableInterval` // hours and is LABELLED with that bucket's LANDING hour — a 4h card // reading "8pm" spans 8–11pm and prints the worst felt temp in that span, // which on a cooling evening is 8pm's own value. So a card must sit over // its landing hour, not over the bucket's temporal middle (which is where // an evenly-divided row of cards puts it: at 4h the "8pm / 28°" card // landed above 9:30pm, past sunset, and pointed into the cold green tail). // // Laying the row out in hour columns fixes that, but a card is `bucket` // hours wide while its landing hour sits only half an hour in from the // bucket's start, so the first card would hang off the left edge. Hence // the inset spacers: a blank half-bucket of track at each end for the // outer cards to overhang into. // // The grid is measured in HALF-hour tracks, because a card centred on its // landing hour starts on a half-hour boundary. All tracks are identical, // and each card SPANS 2*bucket of them (rather than sitting in one track // at width:400%) — spanning divides a card's intrinsic width across the // tracks it covers, so the grid's max-content width stays close to what // it was and mobile doesn't gain a load of extra horizontal scroll. // // tracks = [ pad ][ hour 0 ][ hour 1 ] … [ hour H-1 ][ pad ] // pad = bucket half-hours (>= the (bucket-1)/2 h overhang, + breathing room) // hour j -> centre at (bucket + 2j + 1) / total // card -> spans 2*bucket tracks, starting one half-hour after 2j // => centre = 2j + 1 + bucket == hour j's centre ✓ const fscPlot = (() => { if (!tableRows.length) return null; const cards = tableRows.length; const hourly = visible.length > cards ? visible : tableRows; const hours = hourly.length; const bucket = Math.max(1, tableInterval || 1); const total = 2 * hours + 2 * bucket; // half-hour tracks // Landing-hour index of each card, accumulated so partial buckets at a // day boundary stay correct rather than assuming i * bucket. const landing = []; let j = 0; for (const r of tableRows) { landing.push(j); j += r.isoHours ? r.isoHours.length : 1; } // A card spans 2*bucket tracks, so this keeps its 55px minimum. const unit = 55 / (2 * bucket); return { hourly, hours, bucket, landing, cols: `repeat(${total}, minmax(${unit.toFixed(2)}px, 1fr))`, // Fraction of the track width at which hour j's data point sits. hourAt: j2 => (bucket + 2 * j2 + 1) / total, // grid-column for the card whose landing hour is j (lines are 1-based). cardCol: j2 => `${2 * j2 + 2} / span ${2 * bucket}`, }; })(); // Single colour source for the quick view: the curve's gradient stops AND // the cards' thermal tags both read from here, so a card's connector line // can never land on a shade its own tag contradicts. (The tags used to take // the discrete UTCI_BANDS hex — a flat #90d090 "Comfortable" chip sitting // over a yellow-green 22°C point on the continuous ramp.) // // Pet mode goes through petAirTempRgb, which remaps the pet reading onto // the human scale first (petEquivHumanTemp), exactly as the pet table // columns and pet legend do. const fscRgb = (t, whiteMix) => (simpleTemp === 'furSurfaceT' ? petAirTempRgb : airTempRgb)(t, whiteMix); // ─── 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`
0 ? ' has-event-note' : ''}`}> ${activeEvents.length > 0 && (() => { const ev = activeEvents[noteIndex] || activeEvents[0]; const isPriority = PRIORITY_WEATHER_IDS.has(ev.id); const fmtDate = (iso) => iso ? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }) : null; // Date line for one event — computed per slide because every event is // rendered (see the stack below), not just the visible one. const dateLineFor = (e) => { const startFmt = fmtDate(e.start); const peakFmt = fmtDate(e.peak); const endFmt = fmtDate(e.end); if (!startFmt || !endFmt) return null; if (startFmt === endFmt) return peakFmt ? `Peak ${peakFmt}` : startFmt; return (peakFmt && peakFmt !== startFmt && peakFmt !== endFmt) ? `${startFmt} – ${endFmt} · Peak ${peakFmt}` : `${startFmt} – ${endFmt}`; }; // Warning banners take their background from the day tabs' weather // palette (ev.tint, set in events/weather-checks.js) so a heat alert // reads the same orange as a hot day tab and a rain alert the same // blue. Pastelised hard — the banner is a wide block of body text, so // it needs far more headroom than a small tab — and drawn edge-in to // echo the tabs' radial "colour radiating inward" look. const noteTintStyle = (isPriority && ev.tint) ? (() => { const pale = (amt) => `rgb(${ev.tint.map(c => Math.round(c + (255 - c) * amt)).join(',')})`; const edge = pale(0.55), core = pale(0.82); return { background: `linear-gradient(90deg, ${edge} 0%, ${core} 35%, ${core} 65%, ${edge} 100%)`, borderColor: `rgb(${ev.tint.map(c => Math.round(c * 0.72)).join(',')})`, }; })() : undefined; return html`
${/* Every slide is rendered, all stacked in one CSS grid cell, so the strip is always as tall as the LONGEST event's copy. Rotating to a two-line message then back no longer resizes the banner and shunts the page up and down. Only the active slide is visible; the rest sit at opacity 0 and are hidden from assistive tech. */''}
${activeEvents.map((e, i) => { const dateLine = dateLineFor(e); const isActive = i === noteIndex && !noteFading; return html`
${e.emoji} ${e.title} ${e.message} ${dateLine && html`${dateLine}`}
`; })}
${activeEvents.length > 1 && html`
${activeEvents.map((_, i) => html`
`}
`; })()}

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` `} ${loading && !error && html`
Acquiring forecast data…
`} ${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`} ${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`} ${forecast && days.length > 0 && (() => { const { mainLabel, mainConfigKey } = deriveProfileMain(activeProfile, outdoorsVariant); const fabIcon = activeProfile === 'outdoors' ? (variantIcons[outdoorsVariant] || '🎯') : (FILTER_PROFILES[activeProfile]?.icon || '🎯'); const fabVal = mainConfigKey === 'vehicle' ? (VEHICLE_TYPES[vehicleType]?.name || '') : mainConfigKey === 'indoor' ? (BUILDING_TYPES[buildingType]?.name || '') : mainConfigKey === 'fur' ? (FUR_COLORS[furColor]?.name || '') : (UTCI_ENVIRONMENTS[utciEnv]?.label || ''); return html` `; })()} <${ConfigPanel} open=${panelOpen} onClose=${closePanel} isPro=${isPro} activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols} activityOptions=${activityOptions} placeOptions=${placeOptions} workOptions=${workOptions} activityValue=${activityValue} placeValue=${placeValue} workValue=${workValue} outdoorsVariant=${outdoorsVariant} setOutdoorsVariantAndSave=${setOutdoorsVariantAndSave} setActiveProfile=${setActiveProfile} setVisibleCols=${setVisibleCols} setIndoorMode=${setIndoorMode} setProPromptSource=${setProPromptSource} setProPromptDay=${setProPromptDay} indoorManaged=${indoorManaged} setIndoorManaged=${setIndoorManaged} buildingType=${buildingType} setBuildingType=${setBuildingType} utciEnv=${utciEnv} setUtciEnv=${setUtciEnv} vehicleType=${vehicleType} setVehicleType=${setVehicleType} vehicleSpeed=${vehicleSpeed} setVehicleSpeed=${setVehicleSpeed} vehicleVent=${vehicleVent} setVehicleVent=${setVehicleVent} furColor=${furColor} setFurColor=${setFurColor} skinType=${skinType} setSkinType=${setSkinType} pollenType=${pollenType} setPollenTypeAndSave=${setPollenTypeAndSave} /> ${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} outdoorsVariant=${outdoorsVariant} openPanel=${openPanel} vehicleType=${vehicleType} vehicleSpeed=${vehicleSpeed} buildingType=${buildingType} indoorManaged=${indoorManaged} utciEnv=${utciEnv} furColor=${furColor} dayTabsRef=${dayTabsRef} canScrollLeft=${canScrollLeft} canScrollRight=${canScrollRight} scrollDayTabs=${scrollDayTabs} />`}
View: ${forecastView === 'simple' && (() => { const showFur = visibleCols.furSurfaceT; const showSolar = visibleCols.utciP; const showShade = visibleCols.shadeT; const showVehicle = visibleCols.vehicleT; const showIndoor = visibleCols.indoorT || visibleCols.managedT; if (!showFur && !showSolar && !showShade && !showVehicle && !showIndoor) return null; const furOn = simpleTemp === 'furSurfaceT'; const solarOn = simpleTemp === 'utciAdj'; const shadeOn = simpleTemp === 'shadeT'; const vehicleOn = simpleTemp === 'vehicleT'; const indoorOn = simpleTemp === 'indoorT' || simpleTemp === 'managedT'; // Values (fur colour, vehicle type/speed, building type, // ventilation) are set in the config strip above the day // tabs now — this row is just a tab switcher for which // thermal model drives the quick-view cards below. Sits // directly above col-toggles in normal flow (touching, zero // gap) so it reads as a folder tab attached to that box. return html` ${showSolar && html``} ${showShade && html``} ${showVehicle && html``} ${showIndoor && html``} ${showFur && html``} `; })()}
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} label="Hours" />
${isPro && html`<${Fragment}> ${colOffered('utciP') && html``} ${colOffered('vehicleT') && html``} ${(colOffered('indoorT') || colOffered('managedT')) && html``} ${colOffered('furSurfaceT') && html``} ${colOffered('pawT') && html``} ${colOffered('petShadeT') && html``} ${colOffered('petHomeT') && html``} ${colOffered('burn') && html``} ${colOffered('utci') && html``} ${colOffered('delta') && html``} ${colOffered('tmrt') && html``} ${colOffered('concreteT') && html``} ${colOffered('soilT') && html``} ${colOffered('soilT6') && html``} ${colOffered('soilM') && html``} ${colOffered('shadeT') && html``} ${colOffered('air') && html``} ${colOffered('rh') && html``} ${colOffered('dew') && html``} ${colOffered('precip') && html``} ${colOffered('precipProb') && html``} ${colOffered('lightning') && html``} ${colOffered('cloud') && html``} ${colOffered('vis') && html``} ${colOffered('wind') && html``} ${colOffered('dir') && html``} ${colOffered('aqi') && html``} ${colOffered('pollen') && html``} ${colOffered('uvA') && html``} ${colOffered('uvB') && html``} ${colOffered('sun') && html``} ${colOffered('direct') && html``} ${colOffered('diffuse') && html``} `}
${tableRows.map((r, ri) => { const dispTemp = r[simpleTemp] ?? r.utciAdj; const cat = simpleTemp === 'furSurfaceT' ? petCategory(dispTemp) : 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} showSun=${false} />`; return null; })(); const windMph = Math.round((r.gust ?? r.va) * 2.237); // Tag shade = the exact gradient stop the curve is painted // with at this card's landing hour (fsc-grad, whiteMix 0.42), // so the connector line lands on the colour the tag is // wearing. Band label and weight still come from the band. const feelRgb = fscRgb(dispTemp, 0.42) || [200, 200, 200]; const feelLum = (feelRgb[0] * 299 + feelRgb[1] * 587 + feelRgb[2] * 114) / 1000; const feelStyle = { background: `rgb(${feelRgb[0]},${feelRgb[1]},${feelRgb[2]})`, color: feelLum > 165 ? '#1a1a1a' : '#ffffff', ...(cat.fontWeight ? { fontWeight: cat.fontWeight } : {}), }; 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 (!fscPlot) return null; const fscSvgW = 1000, fscSvgH = 80; const fscSrc = fscPlot.hourly; // 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; // Same hour->x mapping the cards grid uses (see fscPlot above), so a // card's centre and its hour's data point are the same x by // construction — every hour of the day stays on screen. const fscHourX = j => fscPlot.hourAt(j) * fscSvgW; const fscStopPct = j => (fscPlot.hourAt(j) * 100).toFixed(1); const fscAllPts = fscSrc.map((r, j) => ({ x: fscHourX(j), y: toY(getT(r)) })); // Connector points — one per card, planted on its landing hour's data // point, which is exactly where that card is centred. const fscPts = fscPlot.landing.map(j => fscAllPts[Math.min(j, fscAllPts.length - 1)]); const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO); // Flat runs from each edge into the first/last hour, so the fill still // covers the inset spacer strips at both ends. 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 = fscRgb(getT(r), 0.42) || [200, 200, 200]; const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; return html``; })} ${fscSrc.map((r, j) => { const rgb = fscRgb(getT(r), 0.25) || [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.shadeT && html``} ${visibleCols.vehicleT && html``} ${indoorMode === 'on' && !indoorManaged && html``} ${indoorMode === 'on' && indoorManaged && 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.petShadeT && html``} ${visibleCols.petHomeT && 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}> Rows e.stopPropagation()}> <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>SunSoak °C felt${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ? html`${UTCI_ENVIRONMENTS[utciEnv].shortLabel}` : null} handleThClick('shadeT', e)} onMouseEnter=${(e) => handleThEnter('shadeT', e)} onMouseLeave=${handleThLeave}>Shade °C felt 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('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('petShadeT', e)} onMouseEnter=${(e) => handleThEnter('petShadeT', e)} onMouseLeave=${handleThLeave}>Pet Shade °C handleThClick('petHomeT', e)} onMouseEnter=${(e) => handleThEnter('petHomeT', e)} onMouseLeave=${handleThLeave}>Pet Home °C est. 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 ${aqBeyond ? 'no data' : 'EU idx'} handleThClick('pollen', e)} onMouseEnter=${(e) => handleThEnter('pollen', e)} onMouseLeave=${handleThLeave}>${pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen','') ?? 'Pollen'} ${aqBeyond ? 'no data' : '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 } : {}), }; }; // Pet-specific columns (Fur Colour, Pet Shade, Pet Home, Paw) use // petAirTempRgb instead of airTempRgb - identical gradient/blend // mechanics to every other temp column, just a pet-calibrated // scale so the same degree reading lands on a different shade. const petAirTempBg = (t, tPrev, tNext) => { if (t == null) return 'transparent'; const tTop = tPrev != null ? (tPrev + t) / 2 : t; const tBot = tNext != null ? (t + tNext) / 2 : t; return `linear-gradient(to bottom, ${toRgb(petAirTempRgb(tTop))} 0%, ${toRgb(petAirTempRgb(tBot))} 100%)`; }; 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.shadeT && html` `} ${visibleCols.vehicleT && html` `} ${indoorMode === 'on' && !indoorManaged && html` `} ${indoorMode === 'on' && indoorManaged && 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.petShadeT && html` `} ${visibleCols.petHomeT && 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')} ${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'} ${fmt(r.vehicleT)}${u('°C')} ${fmt(r.indoorT)}${u('°C')} ${fmt(r.managedT)}${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.pawT)}${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.petShadeT != null ? fmt(r.petShadeT) + u('°C') : '—'} ${fmt(r.indoorT)}${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].short || COL_DESCRIPTIONS[colPopup.key].desc}

${COL_DESCRIPTIONS[colPopup.key].link && html` More about this column → `}
`} ${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: 25, label: 'Warm', value: '24–27°C' }, { 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} `; })}
${(visibleCols.furSurfaceT || visibleCols.petHomeT || visibleCols.petShadeT || visibleCols.pawT) && html` Pet thermal stress bands
${[ { t: -15, label: 'Freezing', value: '< -8°C' }, { t: 0, label: 'Cold', value: '-8–2°C' }, { t: 9, label: 'Cool', value: '2–11°C' }, { t: 18, label: 'Comfortable', value: '11–25°C', bold: true }, { t: 28, label: 'Warm', value: '25–32°C' }, { t: 36, label: 'Caution', value: '32–40°C' }, { t: 46, label: 'Extreme', value: '40–52°C' }, { t: 60, label: 'Danger', value: '52°C+' }, ].map((b, i) => { // Exact same recipe as the human legend above (same 135deg // light/mid/dark sweep, same luminance-based font colour) - // just reading from petAirTempRgb instead of airTempRgb. const rgb = petAirTempRgb(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` `}
`; }