import { useState, useEffect, useRef } from 'preact/hooks'; import './styles.css'; // ═══════════════════════════════════════════════════════════════════ // PHYSICAL CONSTANTS // ═══════════════════════════════════════════════════════════════════ const SIGMA = 5.670374419e-8; const EPSILON_P = 0.97; const A_K = 0.7; const ALBEDO_GRASS = 0.23; // ═══════════════════════════════════════════════════════════════════ // VAPOR PRESSURE (Magnus formula → hPa) // ═══════════════════════════════════════════════════════════════════ function vaporPressureHpa(Ta, RH) { const es = 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta)); return es * (RH / 100); } // ═══════════════════════════════════════════════════════════════════ // SOLAR POSITION (NOAA simplified; returns elevation in degrees) // ═══════════════════════════════════════════════════════════════════ function solarElevationDeg(lat, lon, dateUTC) { const start = Date.UTC(dateUTC.getUTCFullYear(), 0, 0); const diff = dateUTC - start; const DOY = Math.floor(diff / 86400000); const hourUTC = dateUTC.getUTCHours() + dateUTC.getUTCMinutes() / 60 + dateUTC.getUTCSeconds() / 3600; const gamma = ((2 * Math.PI) / 365) * (DOY - 1 + (hourUTC - 12) / 24); const eqtime = 229.18 * (0.000075 + 0.001868 * Math.cos(gamma) - 0.032077 * Math.sin(gamma) - 0.014615 * Math.cos(2 * gamma) - 0.040849 * Math.sin(2 * gamma)); const decl = 0.006918 - 0.399912 * Math.cos(gamma) + 0.070257 * Math.sin(gamma) - 0.006758 * Math.cos(2 * gamma) + 0.000907 * Math.sin(2 * gamma) - 0.002697 * Math.cos(3 * gamma) + 0.00148 * Math.sin(3 * gamma); const timeOffset = eqtime + 4 * lon; const tst = hourUTC * 60 + timeOffset; const ha = (((tst / 4) - 180) * Math.PI) / 180; const latRad = (lat * Math.PI) / 180; const cosZenith = Math.sin(latRad) * Math.sin(decl) + Math.cos(latRad) * Math.cos(decl) * Math.cos(ha); const zenith = Math.acos(Math.max(-1, Math.min(1, cosZenith))); return (Math.PI / 2 - zenith) * (180 / Math.PI); } // ═══════════════════════════════════════════════════════════════════ // MEAN RADIANT TEMPERATURE // ═══════════════════════════════════════════════════════════════════ function calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) { const TaK = Ta + 273.15; let fp = 0; if (solElev > 0) { const h = solElev; fp = 0.308 * Math.cos((Math.PI / 180) * h * (0.998 - (h * h) / 50000)); } let DNI = 0; if (solElev > 1) { DNI = dirRad / Math.sin((solElev * Math.PI) / 180); DNI = Math.min(DNI, 1100); } const Sshort = A_K * (fp * DNI + 0.5 * diffRad + 0.5 * ALBEDO_GRASS * globalRad); const Slong = EPSILON_P * SIGMA * Math.pow(TaK, 4); const TmrtK = Math.pow((Sshort + Slong) / (EPSILON_P * SIGMA), 0.25); return TmrtK - 273.15; } // ═══════════════════════════════════════════════════════════════════ // UTCI POLYNOMIAL APPROXIMATION (Bröde et al. 2012, 210 terms) // ═══════════════════════════════════════════════════════════════════ function utciApprox(Ta, Tmrt, va10, ehPa) { const va = Math.max(0.5, Math.min(17, va10)); const D_Tmrt = Tmrt - Ta; const Pa = ehPa / 10; const T = Ta, V = va, D = D_Tmrt, P = Pa; const T2=T*T, T3=T2*T, T4=T3*T, T5=T4*T, T6=T5*T; const V2=V*V, V3=V2*V, V4=V3*V, V5=V4*V, V6=V5*V; const D2=D*D, D3=D2*D, D4=D3*D, D5=D4*D, D6=D5*D; const P2=P*P, P3=P2*P, P4=P3*P, P5=P4*P, P6=P5*P; return T + 6.07562052e-1 + -2.27712343e-2 * T + 8.06470249e-4 * T2 + -1.54271372e-4 * T3 + -3.24651735e-6 * T4 + 7.32602852e-8 * T5 + 1.35959073e-9 * T6 + -2.25836520e0 * V + 8.80326035e-2 * T*V + 2.16844454e-3 * T2*V + -1.53347087e-5 * T3*V + -5.72983704e-7 * T4*V + -2.55090145e-9 * T5*V + -7.51269505e-1 * V2 + -4.08350271e-3 * T*V2 + -5.21670675e-5 * T2*V2 + 1.94544667e-6 * T3*V2 + 1.14099531e-8 * T4*V2 + 1.58137256e-1 * V3 + -6.57263143e-5 * T*V3 + 2.22697524e-7 * T2*V3 + -4.16117031e-8 * T3*V3 + -1.27762753e-2 * V4 + 9.66891875e-6 * T*V4 + 2.52785852e-9 * T2*V4 + 4.56306672e-4 * V5 + -1.74202546e-7 * T*V5 + -5.91491269e-6 * V6 + 3.98374029e-1 * D + 1.83945314e-4 * T*D + -1.73754510e-4 * T2*D + -7.60781159e-7 * T3*D + 3.77830287e-8 * T4*D + 5.43079673e-10* T5*D + -2.00518269e-2 * V*D + 8.92859837e-4 * T*V*D + 3.45433048e-6 * T2*V*D + -3.77925774e-7 * T3*V*D + -1.69699377e-9 * T4*V*D + 1.69992415e-4 * V2*D + -4.99204314e-5 * T*V2*D + 2.47417178e-7 * T2*V2*D + 1.07596466e-8 * T3*V2*D + 8.49242932e-5 * V3*D + 1.35191328e-6 * T*V3*D + -6.21531254e-9 * T2*V3*D + -4.99410301e-6 * V4*D + -1.89489258e-8 * T*V4*D + 8.15300114e-8 * V5*D + 7.55043090e-4 * D2 + -5.65095215e-5 * T*D2 + -4.52166564e-7 * T2*D2 + 2.46688878e-8 * T3*D2 + 2.42674348e-10* T4*D2 + 1.54547250e-4 * V*D2 + 5.24110970e-6 * T*V*D2 + -8.75874982e-8 * T2*V*D2 + -1.50743064e-9 * T3*V*D2 + -1.56236307e-5 * V2*D2 + -1.33895614e-7 * T*V2*D2 + 2.49709824e-9 * T2*V2*D2 + 6.51711721e-7 * V3*D2 + 1.94960053e-9 * T*V3*D2 + -1.00361113e-8 * V4*D2 + -1.21206673e-5 * D3 + -2.18203660e-7 * T*D3 + 7.51269482e-9 * T2*D3 + 9.79063848e-11* T3*D3 + 1.25006734e-6 * V*D3 + -1.81584736e-9 * T*V*D3 + -3.52197671e-10* T2*V*D3 + -3.36514630e-8 * V2*D3 + 1.35908359e-10* T*V2*D3 + 4.17032620e-10* V3*D3 + -1.30369025e-9 * D4 + 4.13908461e-10* T*D4 + 9.22652254e-12* T2*D4 + -5.08220384e-9 * V*D4 + -2.24730961e-11* T*V*D4 + 1.17139133e-10* V2*D4 + 6.62154879e-10* D5 + 4.03863260e-13* T*D5 + 1.95087203e-12* V*D5 + -4.73602469e-12* D6 + 5.12733497e0 * P + -3.12788561e-1 * T*P + -1.96701861e-2 * T2*P + 9.99690870e-4 * T3*P + 9.51738512e-6 * T4*P + -4.66426341e-7 * T5*P + 5.48050612e-1 * V*P + -3.30552823e-3 * T*V*P + -1.64119440e-3 * T2*V*P + -5.16670694e-6 * T3*V*P + 9.52692432e-7 * T4*V*P + -4.29223622e-2 * V2*P + 5.00845667e-3 * T*V2*P + 1.00601257e-6 * T2*V2*P + -1.81748644e-6 * T3*V2*P + -1.25813502e-3 * V3*P + -1.79330391e-4 * T*V3*P + 2.34994441e-6 * T2*V3*P + 1.29735808e-4 * V4*P + 1.29064870e-6 * T*V4*P + -2.28558686e-6 * V5*P + -3.69476348e-2 * D*P + 1.62325322e-3 * T*D*P + -3.14279680e-5 * T2*D*P + 2.59835559e-6 * T3*D*P + -4.77136523e-8 * T4*D*P + 8.64203390e-3 * V*D*P + -6.87405181e-4 * T*V*D*P + -9.13863872e-6 * T2*V*D*P + 5.15916806e-7 * T3*V*D*P + -3.59217476e-5 * V2*D*P + 3.28696511e-5 * T*V2*D*P + -7.10542454e-7 * T2*V2*D*P + -1.24382300e-5 * V3*D*P + -7.38584400e-9 * T*V3*D*P + 2.20609296e-7 * V4*D*P + -7.32469180e-4 * D2*P + -1.87381964e-5 * T*D2*P + 4.80925239e-6 * T2*D2*P + -8.75492040e-8 * T3*D2*P + 2.77862930e-5 * V*D2*P + -5.06004592e-6 * T*V*D2*P + 1.14325367e-7 * T2*V*D2*P + 2.53016723e-6 * V2*D2*P + -1.72857035e-8 * T*V2*D2*P + -3.95079398e-8 * V3*D2*P + -3.59413173e-7 * D3*P + 7.04388046e-7 * T*D3*P + -1.89309167e-8 * T2*D3*P + -4.79768731e-7 * V*D3*P + 7.96079978e-9 * T*V*D3*P + 1.62897058e-9 * V2*D3*P + 3.94367674e-8 * D4*P + -1.18566247e-9 * T*D4*P + 3.34678041e-10* V*D4*P + -1.15606447e-10* D5*P + -2.80626406e0 * P2 + 5.48712484e-1 * T*P2 + -3.99428410e-3 * T2*P2 + -9.54009191e-4 * T3*P2 + 1.93090978e-5 * T4*P2 + -3.08806365e-1 * V*P2 + 1.16952364e-2 * T*V*P2 + 4.95271903e-4 * T2*V*P2 + -1.90710882e-5 * T3*V*P2 + 2.10787756e-3 * V2*P2 + -6.98445738e-4 * T*V2*P2 + 2.30109073e-5 * T2*V2*P2 + 4.17856590e-4 * V3*P2 + -1.27043871e-5 * T*V3*P2 + -3.04620472e-6 * V4*P2 + 5.14507424e-2 * D*P2 + -4.32510997e-3 * T*D*P2 + 8.99281156e-5 * T2*D*P2 + -7.14663943e-7 * T3*D*P2 + -2.66016305e-4 * V*D*P2 + 2.63789586e-4 * T*V*D*P2 + -7.01199003e-6 * T2*V*D*P2 + -1.06823306e-4 * V2*D*P2 + 3.61341136e-6 * T*V2*D*P2 + 2.29748967e-7 * V3*D*P2 + 3.04788893e-4 * D2*P2 + -6.42070836e-5 * T*D2*P2 + 1.16257971e-6 * T2*D2*P2 + 7.68023384e-6 * V*D2*P2 + -5.47446896e-7 * T*V*D2*P2 + -3.59937910e-8 * V2*D2*P2 + -4.36497725e-6 * D3*P2 + 1.68737969e-7 * T*D3*P2 + 2.67489271e-8 * V*D3*P2 + 3.23926897e-9 * D4*P2 + -3.53874123e-2 * P3 + -2.21201190e-1 * T*P3 + 1.55126038e-2 * T2*P3 + -2.63917279e-4 * T3*P3 + 4.53433455e-2 * V*P3 + -4.32943862e-3 * T*V*P3 + 1.45389826e-4 * T2*V*P3 + 2.17508610e-4 * V2*P3 + -6.66724702e-5 * T*V2*P3 + 3.33217140e-5 * V3*P3 + -2.26921615e-3 * D*P3 + 3.80261982e-4 * T*D*P3 + -5.45314314e-9 * T2*D*P3 + -7.96355448e-4 * V*D*P3 + 2.53458034e-5 * T*V*D*P3 + -6.31223658e-6 * V2*D*P3 + 3.02122035e-4 * D2*P3 + -4.77403547e-6 * T*D2*P3 + 1.73825715e-6 * V*D2*P3 + -4.09087898e-7 * D3*P3 + 6.14155345e-1 * P4 + -6.16755931e-2 * T*P4 + 1.33374846e-3 * T2*P4 + 3.55375387e-3 * V*P4 + -5.13027851e-4 * T*V*P4 + 1.02449757e-4 * V2*P4 + -1.48526421e-3 * D*P4 + -4.11469183e-5 * T*D*P4 + -6.80434415e-6 * V*D*P4 + -9.77675906e-6 * D2*P4 + 8.82773108e-2 * P5 + -3.01859306e-3 * T*P5 + 1.04452989e-3 * V*P5 + 2.47090539e-4 * D*P5 + 1.48348065e-3 * P6; } // ═══════════════════════════════════════════════════════════════════ // UTCI STRESS BANDS // ═══════════════════════════════════════════════════════════════════ function utciCategory(u) { if (u < -40) return { label: 'Extreme cold', bg: '#1a1438', fg: '#fff' }; if (u < -27) return { label: 'Very strong cold', bg: '#23408f', fg: '#fff' }; if (u < -13) return { label: 'Arctic', bg: '#3f73c4', fg: '#fff' }; if (u < 0) return { label: 'Freezing', bg: '#7eb0e0', fg: '#1a1612' }; if (u < 9) return { label: 'Cold', bg: '#bcd9ec', fg: '#1a1612' }; if (u < 18) return { label: 'Chilled', bg: '#c8dcc0', fg: '#1a1612' }; if (u < 26) return { label: 'Comfortable', bg: '#6ab05a', fg: '#fff' }; if (u < 32) return { label: 'Moderate heat', bg: '#e8c547', fg: '#1a1612' }; if (u < 38) return { label: 'Strong heat', bg: '#dc8a3a', fg: '#1a1612' }; if (u < 46) return { label: 'Very strong heat', bg: '#c44a3a', fg: '#fff' }; return { label: 'Extreme heat', bg: '#7a1a1a', fg: '#fff' }; } // ═══════════════════════════════════════════════════════════════════ // SCOPE RETICLE — shows current UTCI like a precision instrument // (uses only basic SVG primitives for maximum compatibility) // ═══════════════════════════════════════════════════════════════════ function ScopeReticle({ value, cat, loading }) { const cx = 100, cy = 100, R = 86; // 36 tick marks around the ring const ticks = Array.from({ length: 36 }, (_, i) => { const deg = i * 10 - 90; const rad = (deg * Math.PI) / 180; const major = i % 9 === 0; const medium = i % 3 === 0; const r2 = major ? R - 14 : medium ? R - 8 : R - 4; return { x1: cx + R * Math.cos(rad), y1: cy + R * Math.sin(rad), x2: cx + r2 * Math.cos(rad), y2: cy + r2 * Math.sin(rad), major, medium, }; }); // Stress band arc segments: UTCI -40..50 → 135°..405° (270° sweep) const stressBands = [ { min: -40, max: -27, color: '#23408f' }, { min: -27, max: -13, color: '#3f73c4' }, { min: -13, max: 0, color: '#7eb0e0' }, { min: 0, max: 9, color: '#bcd9ec' }, { min: 9, max: 18, color: '#c8dcc0' }, { min: 18, max: 26, color: '#6ab05a' }, { min: 26, max: 32, color: '#e8c547' }, { min: 32, max: 38, color: '#dc8a3a' }, { min: 38, max: 46, color: '#c44a3a' }, { min: 46, max: 50, color: '#7a1a1a' }, ]; function fracToXY(frac, r) { const deg = 135 + frac * 270; const rad = (deg * Math.PI) / 180; return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)]; } function bandArcPath(band) { const f1 = Math.min(1, Math.max(0, (band.min + 10) / 60)); const f2 = Math.min(1, Math.max(0, (band.max + 10) / 60)); const arcR = R - 18; const [x1, y1] = fracToXY(f1, arcR); const [x2, y2] = fracToXY(f2, arcR); const large = (f2 - f1) * 270 > 180 ? 1 : 0; return `M ${x1} ${y1} A ${arcR} ${arcR} 0 ${large} 1 ${x2} ${y2}`; } // Needle position let needleX = cx, needleY = cy + 54; if (value != null) { const frac = Math.min(1, Math.max(0, (value + 10) / 60)); const deg = 135 + frac * 270; const rad = (deg * Math.PI) / 180; needleX = cx + 54 * Math.cos(rad); needleY = cy + 54 * Math.sin(rad); } const glowColor = cat ? cat.bg : '#c8922a'; return ( ); } // ═══════════════════════════════════════════════════════════════════ // MAIN COMPONENT // ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════ // PRECIPITATION PENALTY (applied on top of UTCI) // Rain → evaporative + wet-clothing cooling, amplified by wind // Snow → heavier conductive penalty on top of any rain // ═══════════════════════════════════════════════════════════════════ function precipPenalty(precipMm, snowCmH, windMs) { let penalty = 0; if (precipMm > 0) { // Scales: drizzle 0.1mm→-1.2°, moderate 2mm→-3.8°, heavy 8mm→-6.5° const base = Math.min(7, 1.4 * Math.pow(precipMm, 0.55) + precipMm * 0.28); // Wind amplifies wet chill (up to +35% at gale force) const windMult = 1 + Math.min(0.35, windMs * 0.025); penalty += base * windMult; } if (snowCmH > 0) { // Snow adds extra penalty on top (wet snow especially brutal) penalty += Math.min(6, 2.2 + snowCmH * 1.6); } return -Math.round(penalty * 10) / 10; } export default function UTCIForecast() { const [location, setLocation] = useState({ name: 'Pangbourne, Berkshire', lat: 51.4839, lon: -1.0725, country: 'GB', }); const [forecast, setForecast] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [searching, setSearching] = useState(false); const [selectedDay, setSelectedDay] = useState(0); const [visibleCols, setVisibleCols] = useState({ hour: true, air: true, rh: true, wind: true, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, utciP: true, precip: true, }); const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] })); const searchTimeout = useRef(null); // Geocoding search useEffect(() => { if (searchQuery.length < 2) { setSearchResults([]); return; } if (searchTimeout.current) clearTimeout(searchTimeout.current); searchTimeout.current = setTimeout(async () => { setSearching(true); try { const r = await fetch( `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(searchQuery)}&count=6&language=en&format=json` ); const j = await r.json(); setSearchResults(j.results || []); } catch { setSearchResults([]); } finally { setSearching(false); } }, 300); }, [searchQuery]); // Forecast fetch useEffect(() => { async function load() { setLoading(true); setError(null); try { const url = `https://api.open-meteo.com/v1/forecast` + `?latitude=${location.lat}&longitude=${location.lon}` + `&hourly=temperature_2m,relative_humidity_2m,wind_speed_10m,` + `direct_radiation,diffuse_radiation,shortwave_radiation,cloud_cover,` + `precipitation,snowfall` + `&wind_speed_unit=ms&timezone=auto&forecast_days=3`; const r = await fetch(url); if (!r.ok) throw new Error(`Open-Meteo HTTP ${r.status}`); setForecast(await r.json()); } catch (e) { setError(e.message); } finally { setLoading(false); } } load(); }, [location]); // Compute hourly rows const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => { const Ta = forecast.hourly.temperature_2m[i]; const RH = forecast.hourly.relative_humidity_2m[i]; const va = forecast.hourly.wind_speed_10m[i]; const dir = forecast.hourly.direct_radiation[i] || 0; const dif = forecast.hourly.diffuse_radiation[i] || 0; const glob = forecast.hourly.shortwave_radiation[i] || 0; const cc = forecast.hourly.cloud_cover[i]; const precip = forecast.hourly.precipitation[i] || 0; const snow = forecast.hourly.snowfall[i] || 0; const dt = new Date(iso); const elev = solarElevationDeg(location.lat, location.lon, dt); const eh = vaporPressureHpa(Ta, RH); const Tmrt = calcTmrt(Ta, dir, dif, glob, elev); const utci = utciApprox(Ta, Tmrt, va, eh); const utciAdj = utci + precipPenalty(precip, snow, va); return { iso, dt, Ta, RH, va, dir, dif, glob, cc, precip, snow, elev, Tmrt, utci, utciAdj, eh }; }) : []; // Group by day const days = []; hourlyRows.forEach(row => { const key = row.iso.slice(0, 10); let day = days.find(d => d.key === key); if (!day) { day = { key, date: new Date(row.iso), rows: [] }; days.push(day); } day.rows.push(row); }); const visible = days[selectedDay]?.rows || []; // Current moment — for reticle + row highlight const now = new Date(); const currentRow = hourlyRows.length > 0 ? (hourlyRows.find(row => now.toDateString() === row.dt.toDateString() && now.getHours() === row.dt.getHours() ) ?? hourlyRows.reduce((best, row) => Math.abs(row.dt - now) < Math.abs(best.dt - now) ? row : best)) : null; const currentCat = currentRow ? utciCategory(currentRow.utciAdj) : { bg: '#4a4228', fg: '#ede4cc', label: 'No data' }; return (
| Hour | } {visibleCols.air &&Air °C | } {visibleCols.rh &&RH % | } {visibleCols.wind &&Wind m/s | } {visibleCols.cloud &&Cloud % | } {visibleCols.sun &&Sun elev° | } {visibleCols.direct &&Direct W/m² | } {visibleCols.diffuse &&Diffuse W/m² | } {visibleCols.tmrt &&Tmrt °C | } {visibleCols.delta &&Δ UTCI−Air | } {visibleCols.utci &&UTCI °C felt | } {visibleCols.precip &&Precip mm/h | } {visibleCols.utciP &&UTCI+P °C adj. | }
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {isNow && } {r.dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} | } {visibleCols.air &&{r.Ta.toFixed(1)} | } {visibleCols.rh &&{Math.round(r.RH)} | } {visibleCols.wind &&{r.va.toFixed(1)} | } {visibleCols.cloud &&{Math.round(r.cc)} | } {visibleCols.sun &&{r.elev > 0 ? r.elev.toFixed(1) : '—'} | } {visibleCols.direct &&{Math.round(r.dir)} | } {visibleCols.diffuse &&{Math.round(r.dif)} | } {visibleCols.tmrt &&{r.Tmrt.toFixed(1)} | } {visibleCols.delta &&3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#9a7d5a', fontWeight: 600, }}> {delta > 0 ? '+' : ''}{delta.toFixed(1)} | } {visibleCols.utci &&{r.utci.toFixed(1)} | } {visibleCols.precip &&0 ? '#6090c8' : r.precip > 0 ? '#5090b0' : '#c0a880' }}> {r.snow > 0 ? '❅ ' + r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'} | } {visibleCols.utciP && (() => { const adjCat = utciCategory(r.utciAdj); return{r.utciAdj.toFixed(1)} | ; })()}
SunScope shows how the weather will actually feel on your body — not just the air temperature. It uses the Universal Thermal Climate Index (UTCI), a peer-reviewed biometeorological standard developed by Bröde et al. (2012) that combines air temperature, humidity, wind speed, and solar radiation into a single felt temperature. On a calm, sunny winter day UTCI can read several degrees warmer than the thermometer; on a grey, blustery day it can read far colder. Forecast data is sourced in real time from Open-Meteo, a free and open-source weather API, and solar radiation is used to calculate Mean Radiant Temperature — the heat your skin absorbs from the sun — making SunScope one of the most complete outdoor comfort forecasts available for free.
The UTCI+P column adds the SunScope soak-factor: an original precipitation penalty that accounts for the extra chill of rain and snow on exposed skin and wet clothing. Light drizzle reduces the felt temperature by around 1–2 °C; heavy rain combined with wind can push it down by 7–8 °C. Snow carries an additional penalty on top. The result is a honest, real-world comfort score for any location worldwide — simply search for your town or city and compare the three-day hourly forecast.