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
+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>
);
}