Initial commit: Sunscope UTCI forecast site

This commit is contained in:
fraxle
2026-05-11 09:03:18 +01:00
commit 2e1f3b7c48
9 changed files with 1252 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# OS / editor cruft
.DS_Store
Thumbs.db
*.swp
*~
.vscode/
.idea/
# Node / build (in case you add tooling later)
node_modules/
dist/
.cache/
*.log
# Env / secrets
.env
.env.*
*.key
*.pem
+171
View File
@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>About SunScope · The True Temperature</title>
<meta name="description" content="SunScope uses the Universal Thermal Climate Index (UTCI) and an original soak-factor formula to show how the weather will really feel on your body — including the effect of rain, snow, wind and solar radiation." />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://sunscope.net/about.html" />
<!-- Open Graph -->
<meta property="og:title" content="About SunScope · The True Temperature" />
<meta property="og:description" content="What is UTCI? How does the SunScope soak-factor work? Learn how SunScope gives you a more honest outdoor comfort forecast than standard air temperature." />
<meta property="og:url" content="https://sunscope.net/about.html" />
<meta property="og:type" content="website" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☀️</text></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,900;1,300&family=Manrope:wght@400;600&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="./assets/about.css">
<!-- Structured data for AEO / AI search -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "SunScope",
"url": "https://sunscope.net",
"description": "SunScope is a free outdoor thermal comfort forecast using the Universal Thermal Climate Index (UTCI) and the SunScope soak-factor for precipitation."
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is UTCI?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The Universal Thermal Climate Index (UTCI) is a biometeorological standard that combines air temperature, humidity, wind speed, and solar radiation into a single felt-temperature value. It was developed by Bröde et al. (2012) and is used worldwide in heat-health warning systems."
}
},
{
"@type": "Question",
"name": "What is the SunScope soak-factor?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The SunScope soak-factor is an original precipitation penalty formula that reduces the UTCI value based on rain intensity, snowfall, and wind speed — reflecting the extra chill of wet clothing and skin that standard UTCI does not account for."
}
},
{
"@type": "Question",
"name": "How accurate is SunScope?",
"acceptedAnswer": {
"@type": "Answer",
"text": "SunScope uses Open-Meteo forecast data, which is sourced from ECMWF and national meteorological services. The UTCI calculation follows the peer-reviewed Bröde 2012 polynomial. The soak-factor is an original model and should be treated as an informed estimate rather than a scientific measurement."
}
}
]
}
</script>
</head>
<body>
<header>
<a href="./index.html" class="logo"><span class="logo-sun">SUN</span><span class="logo-scope">Scope</span></a>
<a href="./index.html">Home</a>
<a href="./about.html" class="active">About</a>
</header>
<main>
<h1>About SunScope</h1>
<p class="tagline">The True Temperature — what the weather actually feels like on your body.</p>
<h2>What is SunScope?</h2>
<p>
SunScope is a free outdoor thermal comfort forecast that goes beyond standard air temperature.
Most weather apps tell you it's 12 °C and leave you to guess whether that means t-shirt weather
or a heavy coat. SunScope combines air temperature, humidity, wind speed, and solar radiation
into a single <strong>felt temperature</strong> — so you know what to actually expect when you
step outside.
</p>
<p>
It uses the <strong>Universal Thermal Climate Index (UTCI)</strong>, a peer-reviewed
biometeorological standard developed by <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde et al. (2012)</a>
and used globally in heat-health warning systems and urban planning. Forecast data is sourced
in real time from <strong>Open-Meteo</strong>, a free and open-source weather API drawing on
ECMWF and national met services. SunScope is available for any location worldwide.
</p>
<h2>What is the SunScope soak-factor?</h2>
<p>
Standard UTCI does not account for precipitation — it tells you how cold the wind and sun make
you feel, but not the extra chill of rain soaking your clothing or snow settling on your skin.
The <strong>SunScope soak-factor</strong> fills that gap with an original penalty formula.
</p>
<p>
Light drizzle (under 1 mm/hr) reduces the felt temperature by roughly 12 °C. Heavy rain
combined with strong wind can push it down by 78 °C. Snowfall carries an additional penalty
on top of any rain adjustment. The result is shown in the <strong>UTCI+P</strong> column —
a more honest, real-world comfort score for anyone heading outdoors.
</p>
<h2>Reading the stress bands</h2>
<p>SunScope maps every UTCI+P value to a thermal stress band so you can read conditions at a glance:</p>
<table class="stress-table">
<thead>
<tr><th>UTCI range</th><th>Band</th><th>What it means outdoors</th></tr>
</thead>
<tbody>
<tr><td><span class="swatch" style="background:#23408f"></span>Below 27 °C</td><td>Polar</td><td>Survivable only with expedition-grade kit; do not go outside unprepared</td></tr>
<tr><td><span class="swatch" style="background:#3f73c4"></span>27 to 13 °C</td><td>Arctic</td><td>Very strong cold stress; full winter protection needed</td></tr>
<tr><td><span class="swatch" style="background:#7eb0e0"></span>13 to 0 °C</td><td>Freezing</td><td>Strong cold stress; warm layers essential</td></tr>
<tr><td><span class="swatch" style="background:#bcd9ec"></span>0 to 9 °C</td><td>Cold</td><td>Moderate cold stress; coat and gloves advised</td></tr>
<tr><td><span class="swatch" style="background:#c8dcc0"></span>9 to 18 °C</td><td>Chilled</td><td>Slight cold stress; comfortable with a light jacket</td></tr>
<tr><td><span class="swatch" style="background:#6ab05a"></span>18 to 26 °C</td><td>Comfortable</td><td>No thermal stress; ideal outdoor conditions</td></tr>
<tr><td><span class="swatch" style="background:#f5c842"></span>26 to 32 °C</td><td>Mod heat</td><td>Moderate heat stress; stay hydrated</td></tr>
<tr><td><span class="swatch" style="background:#f5821f"></span>32 to 38 °C</td><td>Strong heat</td><td>Strong heat stress; limit strenuous activity</td></tr>
<tr><td><span class="swatch" style="background:#e03030"></span>38 to 46 °C</td><td>V. strong heat</td><td>Very strong heat stress; seek shade and cool fluids</td></tr>
<tr><td><span class="swatch" style="background:#8b1a1a"></span>Above 46 °C</td><td>Extreme heat</td><td>Extreme heat stress; dangerous for outdoor exposure</td></tr>
</tbody>
</table>
<h2>Frequently asked questions</h2>
<div class="faq-block">
<p class="faq-q">Why does SunScope sometimes show a much higher temperature than my regular weather app?</p>
<p class="faq-a">On clear, sunny days direct solar radiation heats your skin significantly — sometimes adding 10 °C or more to what the air temperature alone would suggest. SunScope calculates Mean Radiant Temperature from shortwave radiation data, capturing this effect. Standard weather apps ignore it entirely.</p>
</div>
<div class="faq-block">
<p class="faq-q">Why does SunScope sometimes show a lower temperature than my regular weather app?</p>
<p class="faq-a">Wind chill and precipitation both reduce felt temperature. If it's raining or blustery, the UTCI+P value will be noticeably lower than the thermometer reading — which is exactly what your body experiences.</p>
</div>
<div class="faq-block">
<p class="faq-q">How far ahead does the forecast go?</p>
<p class="faq-a">SunScope currently shows a 72-hour hourly forecast. Data refreshes automatically from Open-Meteo each time you load the page.</p>
</div>
<div class="faq-block">
<p class="faq-q">Is SunScope free?</p>
<p class="faq-a">SunScope beta is currently free to use for any location worldwide. As the product develops, some features may move to a paid tier — but core forecasting will always be accessible.</p>
</div>
<div class="faq-block">
<p class="faq-q">What data sources does SunScope use?</p>
<p class="faq-a">Forecast data comes from <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a>, which aggregates ECMWF, GFS, and national meteorological service models. The UTCI calculation follows the Bröde et al. 2012 polynomial. Geolocation search uses the Open-Meteo geocoding API.</p>
</div>
<h2>Get in touch</h2>
<p>
SunScope is an independent project. If you have feedback, spotted a bug, or are interested in
using SunScope data for your business or website, feel free to reach out via
<a href="mailto:hello@sunscope.net">hello@sunscope.net</a>.
</p>
</main>
<footer>
&copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
</footer>
</body>
</html>
+154
View File
@@ -0,0 +1,154 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Manrope', sans-serif;
background: #f5edd6;
color: #1e1208;
line-height: 1.7;
min-height: 100vh;
}
a { color: #c8922a; text-decoration: none; }
a:hover { text-decoration: underline; }
header {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 18px 32px;
gap: 28px;
border-bottom: 1px solid #e0d0a8;
}
header a {
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: #b09870;
text-decoration: none;
border-bottom: 1px solid transparent;
padding-bottom: 2px;
transition: color 0.2s, border-color 0.2s;
}
header a:hover { color: #c8922a; border-bottom-color: rgba(200,146,42,0.5); }
header a.active { color: #c8922a; }
.logo {
margin-right: auto;
font-family: 'Fraunces', serif;
font-weight: 900;
font-size: 1.6rem;
color: #1e1208;
letter-spacing: -0.02em;
text-transform: none;
}
.logo-sun { color: #c8922a; font-weight: 900; font-style: normal; }
.logo-scope { font-style: italic; font-weight: 900; color: #1e1208; }
main {
max-width: 780px;
margin: 0 auto;
padding: 3rem 1.5rem 5rem;
}
h1 {
font-family: 'Fraunces', serif;
font-weight: 900;
font-size: 2.2rem;
color: #1e1208;
margin-bottom: 0.3rem;
line-height: 1.15;
}
.tagline {
font-family: 'Fraunces', serif;
font-style: italic;
font-size: 1.05rem;
color: #7a5c2a;
margin-bottom: 2.5rem;
}
h2 {
font-family: 'Fraunces', serif;
font-weight: 900;
font-size: 1.2rem;
color: #1e1208;
margin: 2.5rem 0 0.7rem;
padding-bottom: 0.3rem;
border-bottom: 2px solid #c9b08a;
}
p {
margin-bottom: 1.1rem;
color: #3a2a12;
font-size: 0.97rem;
}
strong { color: #1e1208; font-weight: 600; }
em { color: #7a5c2a; font-style: italic; }
.faq-block {
background: #fef8ea;
border: 1px solid #e0d0a8;
border-radius: 8px;
padding: 1.2rem 1.4rem;
margin-bottom: 1rem;
}
.faq-q {
font-weight: 600;
color: #1e1208;
margin-bottom: 0.4rem;
font-size: 0.97rem;
}
.faq-a {
color: #5a3e1a;
font-size: 0.93rem;
margin: 0;
}
.stress-table {
width: 100%;
border-collapse: collapse;
margin: 1.2rem 0 1.5rem;
font-size: 0.9rem;
}
.stress-table th {
background: #ede4cc;
color: #5a3e1a;
font-weight: 600;
padding: 0.5rem 0.8rem;
text-align: left;
border-bottom: 1px solid #c9b08a;
}
.stress-table td {
padding: 0.45rem 0.8rem;
border-bottom: 1px solid #ede4cc;
color: #3a2a12;
}
.swatch {
display: inline-block;
width: 12px; height: 12px;
border-radius: 3px;
margin-right: 6px;
vertical-align: middle;
}
footer {
text-align: center;
padding: 2rem 1.5rem;
font-size: 0.82rem;
color: #b09870;
border-top: 1px solid #e0d0a8;
}
@media (max-width: 600px) {
h1 { font-size: 1.6rem; }
main { padding: 2rem 1rem 4rem; }
header { padding: 14px 16px; }
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="SunScope - UTCI weather forecast showing real thermal comfort, not just air temperature." />
<title>SunScope · The True Temperature</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☀️</text></svg>" />
<link rel="stylesheet" crossorigin href="./assets/index-CD_5RYSA.css">
<script type="module" crossorigin src="./assets/index-cxJ3Sbx3.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://sunscope.net/sitemap.xml
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://sunscope.net/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://sunscope.net/about</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
+874
View File
@@ -0,0 +1,874 @@
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 (
<svg viewBox="0 0 200 200" className="scope-ring" aria-label="Current UTCI scope readout">
<defs>
<radialGradient id="scope-bg-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color={glowColor} stop-opacity="0.12" />
<stop offset="100%" stop-color={glowColor} stop-opacity="0" />
</radialGradient>
</defs>
{/* Ambient glow */}
<circle cx={cx} cy={cy} r="96" fill="url(#scope-bg-glow)" />
{/* Stress band arcs */}
{stressBands.map((b, i) => (
<path key={i} d={bandArcPath(b)} fill="none"
stroke={b.color} stroke-width="4" opacity="0.4" stroke-linecap="butt" />
))}
{/* Outer ring */}
<circle cx={cx} cy={cy} r={R} fill="none" stroke="#c9b08a" stroke-width="1.5" />
{/* Tick marks */}
{ticks.map((t, i) => (
<line key={i} x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2}
stroke={t.major ? '#c8922a' : t.medium ? '#c9b08a' : '#e0d0b0'}
stroke-width={t.major ? 1.5 : 0.75}
/>
))}
{/* Crosshair lines */}
<line x1={cx - R + 3} y1={cy} x2={cx - 32} y2={cy} stroke="#d4b896" stroke-width="0.75" />
<line x1={cx + 32} y1={cy} x2={cx + R - 3} y2={cy} stroke="#d4b896" stroke-width="0.75" />
<line x1={cx} y1={cy - R + 3} x2={cx} y2={cy - 32} stroke="#d4b896" stroke-width="0.75" />
<line x1={cx} y1={cy + 32} x2={cx} y2={cy + R - 3} stroke="#d4b896" stroke-width="0.75" />
{/* Inner rings */}
<circle cx={cx} cy={cy} r="58" fill="none" stroke="#e8d8c0" stroke-width="0.75" />
<circle cx={cx} cy={cy} r="32" fill="none" stroke="#e8d8c0" stroke-width="0.5" />
{/* Corner bracket markers */}
{[[-1,-1],[1,-1],[-1,1],[1,1]].map(([sx, sy], i) => (
<g key={i}>
<line x1={cx + sx*72} y1={cy + sy*72} x2={cx + sx*60} y2={cy + sy*72}
stroke="#c9b08a" stroke-width="1" />
<line x1={cx + sx*72} y1={cy + sy*72} x2={cx + sx*72} y2={cy + sy*60}
stroke="#c9b08a" stroke-width="1" />
</g>
))}
{/* Needle */}
{value != null && (
<>
<line x1={cx} y1={cy} x2={needleX} y2={needleY}
stroke="#c8922a" stroke-width="3" stroke-linecap="round" opacity="0.18" />
<line x1={cx} y1={cy} x2={needleX} y2={needleY}
stroke="#c8922a" stroke-width="1.5" stroke-linecap="round" opacity="0.95" />
</>
)}
{/* Centre pivot */}
<circle cx={cx} cy={cy} r="5.5" fill="#f5edd6" stroke="#c8922a" stroke-width="1.5" />
<circle cx={cx} cy={cy} r="2.5" fill="#c8922a" />
{/* Readout text */}
{loading ? (
<text x={cx} y={cy + 5} text-anchor="middle"
fill="#b09870" font-size="11" font-family="monospace">· · ·</text>
) : value != null ? (
<>
<text x={cx} y={cy - 8} text-anchor="middle"
fill="#1e1208" font-size="26"
font-family="Fraunces, serif" font-weight="700">
{value.toFixed(1)}°
</text>
<text x={cx} y={cy + 7} text-anchor="middle"
fill="#9a7d5a" font-size="6.5"
font-family="JetBrains Mono, monospace" letter-spacing="2">
UTCI NOW
</text>
<text x={cx} y={cy + 19} text-anchor="middle"
fill={glowColor} font-size="7"
font-family="JetBrains Mono, monospace" letter-spacing="0.8">
{cat.label.toUpperCase()}
</text>
</>
) : (
<text x={cx} y={cy + 5} text-anchor="middle"
fill="#b09870" font-size="8"
font-family="JetBrains Mono, monospace" letter-spacing="1.2">
AWAITING
</text>
)}
</svg>
);
}
// ═══════════════════════════════════════════════════════════════════
// 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 (
<div className="utci-app">
<nav className="utci-topnav">
<a href="./index.html">Home</a>
<a href="./about.html">About</a>
</nav>
<div className="lens-bloom-a" />
<div className="lens-bloom-b" />
<div className="utci-shell">
<div className="utci-header">
<div>
<h1 className="utci-title"><span className="title-sun">SUN</span><span className="title-scope">Scope</span> <sub className="title-beta">beta</sub></h1>
<div className="utci-tagline">See the sun the way your body does.</div>
<div className="utci-subtitle"><a href="https://utci.org/" target="_blank" rel="noopener noreferrer" className="utci-subtitle-link">Universal Thermal Climate Index</a> · Bröde 2012 · Open-Meteo · SunScope soak-factor</div>
<div className="utci-current-loc">
{location.name}
<span className="utci-loc-coords">
{location.lat.toFixed(3)}°, {location.lon.toFixed(3)}°
</span>
</div>
</div>
<div>
<ScopeReticle
value={currentRow?.utciAdj ?? null}
cat={currentCat}
loading={loading}
/>
</div>
<div className="header-right">
<div className="utci-search-wrap">
<label className="utci-search-label">Change location</label>
<input
className="utci-search"
type="text"
placeholder="Search any town or city…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchResults.length > 0 && (
<div className="utci-results">
{searchResults.map((r) => (
<div
key={`${r.id}-${r.latitude}`}
className="utci-result"
onClick={() => {
setLocation({
name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`,
lat: r.latitude,
lon: r.longitude,
country: r.country_code,
});
setSearchQuery('');
setSearchResults([]);
setSelectedDay(0);
}}
>
<div>{r.name}{r.admin1 ? `, ${r.admin1}` : ''}</div>
<div className="utci-result-meta">
{r.country} · {r.latitude.toFixed(2)}°, {r.longitude.toFixed(2)}°
</div>
</div>
))}
</div>
)}
{searching && <div className="utci-searching">Searching</div>}
</div>
</div>
</div>
{error && (
<div className="utci-status" style={{ borderColor: '#3a1010', color: '#c44a3a' }}>
{error}
</div>
)}
{loading && !error && (
<div className="utci-status">Acquiring forecast data</div>
)}
{forecast && days.length > 0 && (
<>
<div className="utci-day-tabs">
{days.map((d, i) => (
<button
key={d.key}
className={`utci-day-tab ${i === selectedDay ? 'active' : ''}`}
onClick={() => setSelectedDay(i)}
>
{i === 0 ? 'Today' : i === 1 ? 'Tomorrow' : 'Day after'}
<span className="utci-day-date">
{d.date.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })}
</span>
</button>
))}
</div>
<div className="col-toggles">
<span className="col-toggles-label">Columns:</span>
{[
{ key: 'hour', label: 'Hour' },
{ key: 'air', label: 'Air' },
{ key: 'rh', label: 'RH' },
{ key: 'wind', label: 'Wind' },
{ key: 'cloud', label: 'Cloud' },
{ key: 'sun', label: 'Sun' },
{ key: 'direct', label: 'Direct' },
{ key: 'diffuse', label: 'Diffuse' },
{ key: 'tmrt', label: 'Tmrt' },
{ key: 'delta', label: 'Δ' },
{ key: 'utci', label: 'UTCI' },
{ key: 'precip', label: 'Precip' },
{ key: 'utciP', label: 'UTCI+P' },
].map(c => (
<button
key={c.key}
className={`col-toggle${visibleCols[c.key] ? ' on' : ''}`}
onClick={() => toggleCol(c.key)}
>
{c.label}
</button>
))}
</div>
<div className="utci-table-wrap">
<table className="utci-table">
<thead>
<tr>
{visibleCols.hour && <th>Hour</th>}
{visibleCols.air && <th>Air <span className="col-unit">°C</span></th>}
{visibleCols.rh && <th>RH <span className="col-unit">%</span></th>}
{visibleCols.wind && <th>Wind <span className="col-unit">m/s</span></th>}
{visibleCols.cloud && <th>Cloud <span className="col-unit">%</span></th>}
{visibleCols.sun && <th>Sun <span className="col-unit">elev°</span></th>}
{visibleCols.direct && <th>Direct <span className="col-unit">W/</span></th>}
{visibleCols.diffuse && <th>Diffuse <span className="col-unit">W/</span></th>}
{visibleCols.tmrt && <th>Tmrt <span className="col-unit">°C</span></th>}
{visibleCols.delta && <th>Δ <span className="col-unit">UTCIAir</span></th>}
{visibleCols.utci && <th>UTCI <span className="col-unit">°C felt</span></th>}
{visibleCols.precip && <th>Precip <span className="col-unit">mm/h</span></th>}
{visibleCols.utciP && <th>UTCI+P <span className="col-unit">°C adj.</span></th>}
</tr>
</thead>
<tbody>
{visible.map((r) => {
const cat = utciCategory(r.utci);
const isNight = r.elev < 0;
const isNow =
now.toDateString() === r.dt.toDateString() &&
now.getHours() === r.dt.getHours();
const delta = r.utci - r.Ta;
return (
<tr key={r.iso}
className={[isNight ? 'is-night' : '', isNow ? 'is-now' : ''].join(' ')}>
{visibleCols.hour && <td className="utci-time">
{isNow && <span className="now-pip" />}
{r.dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
</td>}
{visibleCols.air && <td>{r.Ta.toFixed(1)}</td>}
{visibleCols.rh && <td>{Math.round(r.RH)}</td>}
{visibleCols.wind && <td>{r.va.toFixed(1)}</td>}
{visibleCols.cloud && <td>{Math.round(r.cc)}</td>}
{visibleCols.sun && <td>{r.elev > 0 ? r.elev.toFixed(1) : '—'}</td>}
{visibleCols.direct && <td>{Math.round(r.dir)}</td>}
{visibleCols.diffuse && <td>{Math.round(r.dif)}</td>}
{visibleCols.tmrt && <td>{r.Tmrt.toFixed(1)}</td>}
{visibleCols.delta && <td style={{
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#9a7d5a',
fontWeight: 600,
}}>
{delta > 0 ? '+' : ''}{delta.toFixed(1)}
</td>}
{visibleCols.utci && <td>
<span className="utci-cell" style={{ background: cat.bg, color: cat.fg }}>
{r.utci.toFixed(1)}
</span>
</td>}
{visibleCols.precip && <td style={{ color: r.snow > 0 ? '#6090c8' : r.precip > 0 ? '#5090b0' : '#c0a880' }}>
{r.snow > 0 ? '❅ ' + r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'}
</td>}
{visibleCols.utciP && (() => {
const adjCat = utciCategory(r.utciAdj);
return <td style={{ background: 'rgba(180,215,250,0.10)' }}>
<span className="utci-cell" style={{ background: adjCat.bg, color: adjCat.fg }}>
{r.utciAdj.toFixed(1)}
</span>
</td>;
})()}
</tr>
);
})}
</tbody>
</table>
</div>
<div className="utci-legend">
<span className="utci-legend-label">Thermal stress bands</span>
<div className="utci-legend-row">
{[
{ l: '-27 to -13 Arctic', bg: '#3f73c4', fg: '#fff' },
{ l: '-13 to 0 Freezing', bg: '#7eb0e0', fg: '#111' },
{ l: '0 to 9 Cold', bg: '#bcd9ec', fg: '#111' },
{ l: '9 to 18 Chilled', bg: '#c8dcc0', fg: '#111' },
{ l: '18 to 26 Comfortable', bg: '#6ab05a', fg: '#fff' },
{ l: '26 to 32 Mod heat', bg: '#e8c547', fg: '#111' },
{ l: '32 to 38 Strong heat', bg: '#dc8a3a', fg: '#111' },
{ l: '38 to 46 V. strong', bg: '#c44a3a', fg: '#fff' },
{ l: '> 46 Extreme heat', bg: '#7a1a1a', fg: '#fff' },
].map((b, i) => (
<span key={i} className="utci-legend-item" style={{ background: b.bg, color: b.fg }}>
{b.l}
</span>
))}
</div>
</div>
</>
)}
<div className="utci-about">
<h2 className="utci-about-heading">What is SunScope?</h2>
<p className="utci-about-text">
SunScope shows how the weather will actually <em>feel</em> on your body not just the air
temperature. It uses the <strong>Universal Thermal Climate Index (UTCI)</strong>, 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 <em>felt
temperature</em>. 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 <strong>Open-Meteo</strong>, 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.
</p>
<p className="utci-about-text">
The <strong>UTCI+P</strong> column adds the <strong>SunScope soak-factor</strong>: 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
12 °C; heavy rain combined with wind can push it down by 78 °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.
</p>
</div>
<div className="utci-footer">
<em>Reading the table.</em> A large positive Δ means your body is absorbing
far more heat than the air temperature alone suggests typically due to direct solar radiation.
On clear sunny days this gap can exceed 10°C even at modest air temperatures.
</div>
</div>
</div>
);
}