Files
sunscope/assets/sunscope.js
T
fraxle ced7ddeba7 Beta4
Fix mobile table issues
2026-05-13 02:51:10 +01:00

1794 lines
96 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ════════════════════════════════════════════════════════════════════════
// SUNSCOPE — single-file Preact app, no build step required.
// ════════════════════════════════════════════════════════════════════════
//
// QUICK MAP — where to find common things to change
// ──────────────────────────────────────────────────────────────────────
//
// 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
// Confidence band colours ...... function confidenceBand() (hex codes)
// Sky-circle colours ........... function skyFillForElev() (by elev°)
// UTCI thermal-stress colours .. function utciCategory()
// Big UTCI dial design ......... function ScopeReticle()
// Small sun/moon circle ........ function SkyScope()
// Rain/snow penalty curve ...... function precipPenalty()
// Default columns shown ........ useState({...}) on visibleCols
// Page tagline / about copy .... search for "utci-tagline" or "utci-about-text"
// Starting location ............ useState({...}) on `location` near top of main
//
// HTM SYNTAX QUIRKS (the html`...` template literals look weird at first)
// ──────────────────────────────────────────────────────────────────────
// • Inside backticks, ${value} inserts a JS value
// • Use <${MyComponent}> instead of <MyComponent> for custom components
// • style=${{ color: 'red' }} double braces (CSS as a JS object)
// • class="x" or class=${cond ? 'a' : 'b'} both work
// • Conditionals: ${cond && html`<div>shown when true</div>`}
//
// AFTER EDITING
// ──────────────────────────────────────────────────────────────────────
// 1. Save the file.
// 2. Hard-refresh the page (Ctrl+F5 on Windows · Cmd+Shift+R on Mac).
// 3. To force every visitor to get the fresh version, bump the cache
// buster in index.html: sunscope.js?v=14d-local → ?v=14e
// 4. Open the browser console (F12 → Console) to see any errors —
// they always include the line number that broke.
//
// SAFETY TIP — before a big change, copy this file to sunscope.js.bak.
// If something breaks, just rename the backup back.
// ════════════════════════════════════════════════════════════════════════
// Tiny framework imports — these are the only three "outside" dependencies.
// All three live in assets/vendor/ so the site has no external requests.
import { h, render, Fragment } from './vendor/preact.js';
import { useState, useEffect, useLayoutEffect, useRef } from './vendor/preact-hooks.js';
import htm from './vendor/htm.js';
// `html` is the magic tagged-template function. Use it like:
// html`<div class="foo">${someValue}</div>`
const html = htm.bind(h);
// ═══════════════════════════════════════════════════════════════════
// PHYSICAL CONSTANTS
// ───────────────────────────────────────────────────────────────────
// These are real-world physics values — don't change them unless you
// have a peer-reviewed reason. They're used by calcTmrt() below to
// work out how much heat your skin actually absorbs from the sun.
// SIGMA — StefanBoltzmann constant (radiates heat)
// EPSILON_P — emissivity of human skin (~0.97)
// A_K — short-wave absorption coefficient for clothing
// ALBEDO_GRASS — how much sun grass reflects back at you (23%)
// ═══════════════════════════════════════════════════════════════════
const SIGMA = 5.670374419e-8;
const EPSILON_P = 0.97;
const A_K = 0.7;
const ALBEDO_GRASS = 0.23;
// Vapour pressure (Magnus → hPa)
function vaporPressureHpa(Ta, RH) {
const es = 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
return es * (RH / 100);
}
// Solar elevation (NOAA simplified, 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 h2 = solElev;
fp = 0.308 * Math.cos((Math.PI / 180) * h2 * (0.998 - (h2 * h2) / 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 — DO NOT EDIT (or be VERY careful if you do)
// ───────────────────────────────────────────────────────────────────
// This is the official 210-term Bröde et al. (2012) approximation.
// It takes air temp, mean radiant temp, wind, and humidity and returns
// the "felt" temperature. Every number is a peer-reviewed coefficient.
// Scroll past it — there's nothing here you'll want to change.
// ═══════════════════════════════════════════════════════════════════
// UTCI 210-term polynomial (Bröde et al. 2012)
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 THERMAL STRESS BANDS — the coloured pills in the table.
// ───────────────────────────────────────────────────────────────────
// To recolour any band, change its bg/fg hex code. To shift the
// boundary between bands (e.g. make "Comfortable" wider), change the
// `if (u < …)` thresholds. Order matters — they're checked top-down.
// ═══════════════════════════════════════════════════════════════════
// 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' };
}
// ═══════════════════════════════════════════════════════════════════
// SOAK-FACTOR — SunScope's original rain/snow penalty.
// ───────────────────────────────────────────────────────────────────
// This is what makes "UTCI+P" different from plain UTCI. It subtracts
// extra felt-temperature for rain (wet clothing = evaporative chill)
// and snow (wet snow is brutal). Wind amplifies the rain penalty.
// Calibrated by feel — adjust the multipliers if you find it too
// strong/weak. The big number "7" caps the max rain penalty so a
// freak 50mm/h reading can't make UTCI nonsensical.
// ═══════════════════════════════════════════════════════════════════
// Precipitation penalty (the SunScope soak-factor)
function precipPenalty(precipMm, snowCmH, windMs) {
let penalty = 0;
if (precipMm > 0) {
const base = Math.min(7, 1.4 * Math.pow(precipMm, 0.55) + precipMm * 0.28);
const windMult = 1 + Math.min(0.35, windMs * 0.025);
penalty += base * windMult;
}
if (snowCmH > 0) penalty += Math.min(6, 2.2 + snowCmH * 1.6);
return -Math.round(penalty * 10) / 10;
}
// ═══════════════════════════════════════════════════════════════════
// WIND COMPASS — meteorological bearing (deg FROM) → 8-point label.
// ───────────────────────────────────────────────────────────────────
// 0/360 = wind FROM north. The pointer in WindVane should rotate so
// the arrow's tail points to this bearing (i.e. shows where the wind
// comes from), matching how real weather vanes behave.
// ═══════════════════════════════════════════════════════════════════
function windCompass8(deg) {
if (deg == null || isNaN(deg)) return { label: '—', snapped: 0 };
const dirs = ['N','NE','E','SE','S','SW','W','NW'];
const idx = Math.round(((deg % 360) + 360) % 360 / 45) % 8;
return { label: dirs[idx], snapped: idx * 45 };
}
// ═══════════════════════════════════════════════════════════════════
// UV-A / UV-B SPLIT (estimate, not measurement).
// ───────────────────────────────────────────────────────────────────
// Open-Meteo gives a total erythemal UV index. UV-A reaches the
// surface much more reliably than UV-B; UV-B is far more sensitive
// to solar elevation because of atmospheric path length.
//
// Cheap model:
// At noon (sun overhead) the UV-B share of total UV index is ~15%,
// UV-A about 85%. Below ~10° solar elevation, UV-B falls off fast.
// We return two pseudo-"index" numbers so the columns are in the
// same units the user already understands.
// Label these as estimates in the UI.
// ═══════════════════════════════════════════════════════════════════
function uvSplit(uv, elevDeg) {
if (!uv || uv <= 0 || elevDeg <= 0) return { uvA: 0, uvB: 0 };
const sinE = Math.sin(elevDeg * Math.PI / 180);
const uvbFr = Math.max(0.005, Math.min(0.15, 0.15 * Math.pow(sinE, 1.6)));
const uvaFr = 1 - uvbFr;
return { uvA: uv * uvaFr, uvB: uv * uvbFr };
}
// ═══════════════════════════════════════════════════════════════════
// SUNBURN TIME — minutes to MED for the chosen Fitzpatrick skin type.
// ───────────────────────────────────────────────────────────────────
// Standard erythemal model: time_min ≈ base_minutes[type] / UV_index.
// Numbers are the well-known "unprotected, midday, no sunscreen"
// reference times at UV = 1. Returns Infinity when UV is 0 (night).
// ═══════════════════════════════════════════════════════════════════
const SKIN_TYPES = {
I: { name: 'I · Very fair', base: 67 },
II: { name: 'II · Fair', base: 100 },
III: { name: 'III · Light', base: 200 },
IV: { name: 'IV · Mid', base: 300 },
V: { name: 'V · Dark', base: 400 },
VI: { name: 'VI · Very dark', base: 500 },
};
function sunburnMinutes(uv, skinType = 'II') {
if (!uv || uv <= 0) return Infinity;
const base = (SKIN_TYPES[skinType] || SKIN_TYPES.II).base;
return base / uv;
}
function burnLabel(mins) {
if (!isFinite(mins)) return '—';
if (mins >= 480) return '8h+';
if (mins >= 60) return `${(mins/60).toFixed(1)}h`;
return `${Math.round(mins)}m`;
}
// ═══════════════════════════════════════════════════════════════════
// CLOUD CATEGORY — pick one of 4 icon styles from low/mid/high split.
// ───────────────────────────────────────────────────────────────────
// Returns: 'clear' | 'wispy' | 'scattered' | 'overcast'
// Uses total cover for headline level, but biases towards 'wispy'
// when only high cloud is present (cirrus barely blocks the sun).
// ═══════════════════════════════════════════════════════════════════
function cloudCategory(total, low, mid, high) {
const t = total ?? 0;
const l = low ?? 0;
const m = mid ?? 0;
const h = high ?? 0;
if (t < 10) return 'clear';
// Mostly high cloud with little low/mid → wispy regardless of % total
if (h > 40 && l < 25 && m < 25) return 'wispy';
if (t < 40) return 'wispy';
if (t < 75) return 'scattered';
return 'overcast';
}
// ═══════════════════════════════════════════════════════════════════
// CONFIDENCE BANDS — smooth high-noon → sunset gradient on day tabs.
// ───────────────────────────────────────────────────────────────────
// `i` is the day index (0 = today, 13 = day 14).
//
// Each day gets its own shade, interpolated between two endpoint
// colours. To re-skin the gradient (say, blue-to-purple instead of
// sunset), just change the four RGB endpoint arrays below.
//
// bg = tab background (lighter on day 0, darker on day 13)
// edge = active-tab underline + slim border on the banner
// tint = soft wash on the confidence banner above the table
// label = qualitative zone name shown in the banner ("Golden hour")
//
// The label switches in 4 stages so users still see a friendly
// description ("you're in the trustworthy zone" vs "this is an
// outlook"). The tab background itself flows smoothly day to day.
// ═══════════════════════════════════════════════════════════════════
function confidenceBand(i) {
// Position along the gradient: 0 on day 0, 1 on day 13.
const t = Math.min(1, Math.max(0, i / 13));
// ENDPOINTS — change these four RGB arrays to re-skin the gradient.
const bgStart = [255, 247, 214]; // #fff7d6 pale yellow (high noon)
const bgEnd = [232, 152, 104]; // #e89868 terracotta (sunset)
const edgeStart = [245, 231, 161]; // #f5e7a1 soft golden
const edgeEnd = [196, 115, 64]; // #c47340 burnt umber
// Linear interpolation between the two endpoints.
const lerp = (a, b) => Math.round(a + t * (b - a));
const mix = (s, e) => [lerp(s[0], e[0]), lerp(s[1], e[1]), lerp(s[2], e[2])];
const [br, bg, bb] = mix(bgStart, bgEnd);
const [er, eg, eb] = mix(edgeStart, edgeEnd);
return {
bg: `rgb(${br}, ${bg}, ${bb})`,
edge: `rgb(${er}, ${eg}, ${eb})`,
tint: `rgba(${er}, ${eg}, ${eb}, 0.22)`,
// Qualitative confidence label (camera-focus metaphor —
// on-brand for SunScope, and instantly readable).
label: i < 3 ? 'Pin-sharp' // days 13 highest skill
: i < 7 ? 'Sharp' // days 47 solid
: i < 10 ? 'Soft focus' // days 810 trends only
: 'Blurry', // days 1114 outlook only
};
}
// ═══════════════════════════════════════════════════════════════════
// MOON PHASE — works out which moon emoji to show on night hours.
// ───────────────────────────────────────────────────────────────────
// Returns a fraction 0..1:
// 0.00 = new moon 0.50 = full moon
// 0.25 = first quarter 0.75 = last quarter
// The maths is a simple synodic-period calculation referenced from
// a known new moon (6 Jan 2000). Accurate to within a few hours.
// ═══════════════════════════════════════════════════════════════════
// Moon phase
function moonPhaseFraction(date) {
const JD = date.getTime() / 86400000 + 2440587.5;
const syn = 29.530588;
const ref = 2451550.1;
let p = ((JD - ref) % syn) / syn;
if (p < 0) p += 1;
return p;
}
function moonGlyph(p) {
return ['🌑','🌒','🌓','🌔','🌕','🌖','🌗','🌘'][Math.floor(p*8 + 0.5) % 8];
}
// ═══════════════════════════════════════════════════════════════════
// SKY FILL — colour inside the little circle, based on sun elevation.
// ───────────────────────────────────────────────────────────────────
// > 30° high noon (bright blue)
// > 10° mid-sky (pale blue)
// > 3° morning/afternoon (warm gold)
// > -3° golden hour / sunset (deep amber)
// > -8° civil twilight (dusky lilac)
// > -14° nautical twilight (deep blue-violet)
// else night (indigo)
// To shift "when sunset starts" visually, tweak the elevation
// thresholds. To recolour: replace the hex codes.
// ═══════════════════════════════════════════════════════════════════
// Sky-fill colour by solar elevation
function skyFillForElev(e) {
if (e > 30) return '#9fd3ef';
if (e > 10) return '#c8e3ee';
if (e > 3) return '#ffd596';
if (e > -3) return '#f59b6e';
if (e > -8) return '#9279b0';
if (e > -14) return '#3a2f60';
return '#1a1538';
}
// Grass palette — mirrors skyFillForElev but for the ground.
// `top` = grass blade tips (catches sky light), `bot` = soil shadow.
function grassFillForElev(e) {
if (e > 30) return { top: '#86c44a', bot: '#558a2e' }; // bright noon grass
if (e > 10) return { top: '#7ab846', bot: '#4d802c' }; // mid-day
if (e > 3) return { top: '#b8a83e', bot: '#7a6a26' }; // golden-hour glow
if (e > -3) return { top: '#c08048', bot: '#7a4a2a' }; // sunrise/sunset embers
if (e > -8) return { top: '#665884', bot: '#3e3556' }; // civil twilight purple
if (e > -14) return { top: '#363356', bot: '#1c1a34' }; // nautical night
return { top: '#201d3a', bot: '#0d0b20' }; // astronomical night
}
// ═══════════════════════════════════════════════════════════════════
// SKYSCOPE — the little porthole circle next to each hour.
// ───────────────────────────────────────────────────────────────────
// What gets drawn (back to front):
// 1. Sky disk — colour from skyFillForElev() above
// 2. Horizon line — faint dashed line at the middle
// 3. Sun OR moon — sun positioned vertically by elevation;
// moon shows real phase (synodic period)
// 4. Brass ring — the telescope/scope edge (#c8922a)
// 5. Lens highlight — subtle inner glint
//
// Tweakable bits inside this function:
// • size — pass a different size= when calling for bigger/smaller
// • innerR — how thick the brass ring looks
// • sunR — the sun's drawn radius
// • the stroke colour #c8922a is the brass — change for a different metal
// ═══════════════════════════════════════════════════════════════════
function SkyScope({ elev, dt, size = 26 }) {
const r = size / 2;
const innerR = r - 1.2;
const skyFill = skyFillForElev(elev);
const isDay = elev > -3;
const elevClamped = Math.max(-30, Math.min(90, elev));
const sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5);
const sunR = Math.max(2.2, innerR * 0.32);
const phase = moonPhaseFraction(dt);
const moonR = innerR * 0.55;
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR;
const moonLitFromRight = phase < 0.5;
const shadowCx = r + (moonLitFromRight ? -phaseOffset : phaseOffset);
const grass = grassFillForElev(elev);
const uid = `${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}`;
const clipId = `scope-clip-${uid}`;
const grassId = `scope-grass-${uid}`;
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle' }}>
<defs>
<clipPath id=${clipId}>
<circle cx=${r} cy=${r} r=${innerR} />
</clipPath>
<linearGradient id=${grassId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color=${grass.top} />
<stop offset="100%" stop-color=${grass.bot} />
</linearGradient>
</defs>
<circle cx=${r} cy=${r} r=${innerR} fill=${skyFill} />
<path d=${`M ${r - innerR} ${r} A ${innerR} ${innerR} 0 0 0 ${r + innerR} ${r} Z`}
fill=${`url(#${grassId})`}
clip-path=${`url(#${clipId})`} />
${isDay
? html`
<g clip-path=${`url(#${clipId})`}>
<circle cx=${r} cy=${sunY} r=${sunR + 1.4} fill="#ffe9a0" opacity="0.55" />
<circle cx=${r} cy=${sunY} r=${sunR} fill="#fff3a8" stroke="#e6a32a" stroke-width="0.4" />
</g>`
: html`
<g clip-path=${`url(#${clipId})`}>
<circle cx=${r} cy=${r} r=${moonR} fill="#f5edd6" />
<circle cx=${shadowCx} cy=${r} r=${moonR} fill=${skyFill} />
<circle cx=${r} cy=${r} r=${moonR} fill="none" stroke="rgba(245,237,214,0.45)" stroke-width="0.4" />
</g>`}
<circle cx=${r} cy=${r} r=${innerR} fill="none" stroke="#c8922a" stroke-width="0.9" />
<circle cx=${r} cy=${r} r=${innerR - 0.5} fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="0.4" />
</svg>`;
}
// ═══════════════════════════════════════════════════════════════════
// WINDVANE — clean compass arrow on transparent background.
// ───────────────────────────────────────────────────────────────────
// Traditional weather-vane behaviour: the ARROWHEAD points INTO the
// wind (toward the source). Wind from the north → head points north.
//
// props:
// bearing — degrees, 0 = wind FROM north
// size — pixel diameter (default 30 to match SkyScope)
//
// Tweakable bits:
// • arrowColor / nMarkerColor — line/fill colours
// • Tiny N letter sits just above the arrow tail for orientation
// ═══════════════════════════════════════════════════════════════════
function WindVane({ bearing, size = 30 }) {
if (bearing == null || isNaN(bearing)) {
return html`<span style=${{ opacity: 0.5, fontSize: '11px' }}>—</span>`;
}
const r = size / 2;
// The shaft is drawn pointing DOWN by default (head at the bottom).
// To place the head at the bearing direction, rotate by bearing + 180.
const rot = (bearing + 180) % 360;
const arrowColor = '#2a1a08';
const headColor = '#c44a3a';
const nColor = '#9a7d5a';
// Geometry of the single-ended arrow (drawn pointing DOWN by default).
// After rotation by (bearing + 180), the head lands on the bearing
// direction — i.e. the side the wind is coming FROM.
const tipY = r * 0.15; // arrowhead tip
const headBase = r * 0.58; // bottom of the triangle head
const headW = r * 0.42;
const tailEndY = r * 1.78; // shaft's tail end
const tailDotR = Math.max(1.0, size * 0.06);
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<!-- Static N marker — small letter above the centre, doesn't rotate -->
<text x=${r} y=${size * 0.18} text-anchor="middle"
font-family="JetBrains Mono, monospace"
font-size=${size * 0.26} font-weight="700"
fill=${nColor} opacity="0.55">N</text>
<!-- Rotating arrow. Shaft + ONE arrowhead. A small round nub on
the tail end keeps the orientation unambiguous without
looking like a second arrowhead. -->
<g transform=${`rotate(${rot} ${r} ${r})`}>
<!-- shaft -->
<line x1=${r} y1=${headBase} x2=${r} y2=${tailEndY}
stroke=${arrowColor} stroke-width=${Math.max(1.4, size*0.07)} stroke-linecap="round" />
<!-- single arrowhead at the upwind end (head into the wind) -->
<polygon points=${`${r - headW},${headBase} ${r + headW},${headBase} ${r},${tipY}`}
fill=${headColor} stroke=${arrowColor} stroke-width="0.6" stroke-linejoin="round" />
<!-- small dot at the downwind end of the shaft, so orientation
is unambiguous (no chance of reading the tail as a head) -->
<circle cx=${r} cy=${tailEndY} r=${tailDotR} fill=${arrowColor} />
</g>
</svg>`;
}
// ═══════════════════════════════════════════════════════════════════
// CLOUDICON — clouds + sun/moon on a fully transparent background.
// No rim, no disc. Day/night aware: when elev < 0, the sun is
// replaced with a phased moon (same logic as SkyScope).
// ───────────────────────────────────────────────────────────────────
// category: 'clear' | 'wispy' | 'scattered' | 'overcast'
// elev: solar elevation in degrees (negative = night)
// dt: Date used for moon phase
// ═══════════════════════════════════════════════════════════════════
function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
const r = size / 2;
const u = r; // half-extent for layout
const isNight = elev < -3;
// Phased moon helper — returns an SVG <g> with the moon + shadow.
// Lit fraction direction matches SkyScope (waxing = lit from right).
const renderMoon = (cx, cy, moonR) => {
const phase = moonPhaseFraction(dt);
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR;
const moonLitFromRight = phase < 0.5;
const shadowCx = cx + (moonLitFromRight ? -phaseOffset : phaseOffset);
const clipId = `cmoon-${size}-${Math.round(cx)}-${Math.round(cy)}-${Math.round(phase * 1000)}`;
return html`
<g>
<defs>
<clipPath id=${clipId}>
<circle cx=${cx} cy=${cy} r=${moonR} />
</clipPath>
</defs>
<circle cx=${cx} cy=${cy} r=${moonR} fill="#f5edd6" stroke="#9a8c6a" stroke-width="0.4" />
<circle cx=${shadowCx} cy=${cy} r=${moonR} fill="#2a2438" clip-path=${`url(#${clipId})`} />
<circle cx=${cx} cy=${cy} r=${moonR} fill="none" stroke="#9a8c6a" stroke-width="0.4" />
</g>`;
};
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
${category === 'clear' && (isNight
? html`<!-- clear night: phased moon, no rays -->
${renderMoon(r, r, u*0.50)}`
: html`<!-- clear day: bright sun with rays -->
<g>
${[0,45,90,135,180,225,270,315].map(a => {
const x1 = r + Math.sin(a*Math.PI/180) * u*0.65;
const y1 = r - Math.cos(a*Math.PI/180) * u*0.65;
const x2 = r + Math.sin(a*Math.PI/180) * u*0.95;
const y2 = r - Math.cos(a*Math.PI/180) * u*0.95;
return html`<line key=${a} x1=${x1} y1=${y1} x2=${x2} y2=${y2}
stroke="#e6a32a" stroke-width=${Math.max(1, size*0.05)}
stroke-linecap="round" />`;
})}
<circle cx=${r} cy=${r} r=${u*0.45} fill="#ffd84a" stroke="#e6a32a" stroke-width="0.6" />
</g>`)}
${category === 'wispy' && html`
<!-- sun OR moon with thin cirrus streaks across it -->
${isNight
? renderMoon(r, r*0.95, u*0.42)
: html`<circle cx=${r} cy=${r*0.95} r=${u*0.42} fill="#ffd84a" stroke="#e6a32a" stroke-width="0.6" />`}
<path d=${`M ${r-u*0.95} ${r*0.85} Q ${r} ${r*0.65} ${r+u*0.95} ${r*0.95}`}
stroke=${isNight ? '#cfd6dd' : '#ffffff'} stroke-width=${Math.max(1.4, size*0.07)}
fill="none" stroke-linecap="round" opacity="0.92" />
<path d=${`M ${r-u*0.75} ${r*1.25} Q ${r} ${r*1.08} ${r+u*0.85} ${r*1.30}`}
stroke=${isNight ? '#b8c0c8' : '#e6ebf0'} stroke-width=${Math.max(1.1, size*0.055)}
fill="none" stroke-linecap="round" opacity="0.85" />`}
${category === 'scattered' && html`
<!-- sun OR moon behind a fluffy cumulus cloud -->
${isNight
? renderMoon(r+u*0.45, r-u*0.40, u*0.32)
: html`<circle cx=${r+u*0.45} cy=${r-u*0.40} r=${u*0.34} fill="#ffd84a" stroke="#e6a32a" stroke-width="0.6" />`}
<g fill=${isNight ? '#b8c0c8' : '#ffffff'} stroke=${isNight ? '#5a6470' : '#8a96a4'} stroke-width="0.6" stroke-linejoin="round">
<ellipse cx=${r-u*0.35} cy=${r+u*0.10} rx=${u*0.50} ry=${u*0.30} />
<ellipse cx=${r+u*0.10} cy=${r-u*0.05} rx=${u*0.46} ry=${u*0.36} />
<ellipse cx=${r+u*0.40} cy=${r+u*0.20} rx=${u*0.44} ry=${u*0.28} />
<!-- flat-ish base, soft underneath -->
<rect x=${r-u*0.75} y=${r+u*0.18} width=${u*1.55} height=${u*0.20} rx=${u*0.10} />
</g>`}
${category === 'overcast' && html`
<!-- thick layered stratus, no sun OR moon (would be hidden anyway) -->
<g fill=${isNight ? '#7a828c' : '#d8dee5'} stroke=${isNight ? '#3a424c' : '#6e7a88'} stroke-width="0.7" stroke-linejoin="round">
<ellipse cx=${r-u*0.40} cy=${r-u*0.20} rx=${u*0.55} ry=${u*0.32} />
<ellipse cx=${r+u*0.20} cy=${r-u*0.40} rx=${u*0.48} ry=${u*0.30} />
<ellipse cx=${r+u*0.40} cy=${r+u*0.10} rx=${u*0.55} ry=${u*0.34} />
<ellipse cx=${r-u*0.10} cy=${r+u*0.30} rx=${u*0.60} ry=${u*0.32} />
</g>`}
</svg>`;
}
// ═══════════════════════════════════════════════════════════════════
// SCOPERETICLE — the big circular UTCI dial in the page header.
// ───────────────────────────────────────────────────────────────────
// This is the one with tick marks, the swept colour band, and the
// needle pointing at the current felt-temperature.
//
// Tweakable bits:
// • R = 86 outer ring radius (changes overall size)
// • cx, cy = 100 centre point (leave alone unless you also
// change the viewBox="0 0 200 200" below)
// • { length: 36 } number of tick marks (1 every 10°)
// • stressBands[] the colour ramp around the rim (matches UTCI)
// • (value + 10) / 60 maps UTCI -10..50 onto the 270° sweep —
// widen the dial range by changing those numbers
// ═══════════════════════════════════════════════════════════════════
function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) {
// ── Day/night scene behind the readout ─────────────────────────────
// The whole back of the lens is a SkyScope-style scene: sky on top,
// grass on the bottom, sun positioned by elevation (or moon at night
// with its phase shadow). Mirrors the little hourly SkyScopes.
const cx = 100, cy = 100, R = 86;
const isDay = elev > -3;
const skyFill = skyFillForElev(elev);
const grass = grassFillForElev(elev);
const phase = moonPhaseFraction(dt);
const lensR = 95; // full back of the dial
const elevClamped = Math.max(-30, Math.min(90, elev));
const sunY = cy - Math.sin((elevClamped * Math.PI) / 180) * (lensR - 18);
const sunDrawR = 14;
const moonDrawR = 15;
const moonY = cy - 45; // float in the upper sky, away from the readout
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonDrawR;
const moonLitFromRight = phase < 0.5;
const moonShadowCx = cx + (moonLitFromRight ? -phaseOffset : phaseOffset);
// Readout text colours flip with day/night for legibility against the sky.
const readoutColor = isDay ? '#1e1208' : '#f5edd6';
const readoutMutedColor = isDay ? '#9a7d5a' : '#d4c5a8';
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,
};
});
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}`;
}
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 html`
<svg viewBox="0 0 200 200" class="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>
<clipPath id="scope-lens-clip">
<circle cx=${cx} cy=${cy} r=${lensR} />
</clipPath>
<linearGradient id="scope-lens-grass" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color=${grass.top} />
<stop offset="100%" stop-color=${grass.bot} />
</linearGradient>
</defs>
<circle cx=${cx} cy=${cy} r="96" fill="url(#scope-bg-glow)" />
<!-- Full sky + grass + sun/moon scene behind the readout -->
<g clip-path="url(#scope-lens-clip)">
<circle cx=${cx} cy=${cy} r=${lensR} fill=${skyFill} />
<path d=${`M ${cx - lensR} ${cy} A ${lensR} ${lensR} 0 0 0 ${cx + lensR} ${cy} Z`}
fill="url(#scope-lens-grass)" />
${isDay
? html`<${Fragment}>
<circle cx=${cx} cy=${sunY} r=${sunDrawR + 4} fill="#ffe9a0" opacity="0.55" />
<circle cx=${cx} cy=${sunY} r=${sunDrawR} fill="#fff3a8" stroke="#e6a32a" stroke-width="0.8" />
</>`
: html`<${Fragment}>
<circle cx=${cx} cy=${moonY} r=${moonDrawR} fill="#f5edd6" />
<circle cx=${moonShadowCx} cy=${moonY} r=${moonDrawR} fill=${skyFill} />
<circle cx=${cx} cy=${moonY} r=${moonDrawR} fill="none" stroke="rgba(245,237,214,0.55)" stroke-width="0.7" />
</>`}
</g>
${stressBands.map((b, i) => html`
<path key=${i} d=${bandArcPath(b)} fill="none"
stroke=${b.color} stroke-width="4" stroke-linecap="butt" />`)}
<circle cx=${cx} cy=${cy} r=${R} fill="none" stroke="#c9b08a" stroke-width="1.5" />
${ticks.map((t, i) => html`
<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} />`)}
<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" />
<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" />
${[[-1,-1],[1,-1],[-1,1],[1,1]].map(([sx, sy], i) => html`
<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>`)}
${value != null && html`
<${Fragment}>
<line x1=${cx} y1=${cy} x2=${needleX} y2=${needleY}
stroke="#c8922a" stroke-width="5" stroke-linecap="round" opacity="0.18" />
<line x1=${cx} y1=${cy} x2=${needleX} y2=${needleY}
stroke="#c8922a" stroke-width="2.5" stroke-linecap="round" opacity="0.95" />
</>`}
<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" />
${loading
? html`<text x=${cx} y=${cy + 5} text-anchor="middle"
fill=${readoutMutedColor} font-size="11" font-family="monospace">· · ·</text>`
: value != null
? html`<${Fragment}>
<text x=${cx} y="152" text-anchor="middle"
fill=${readoutColor} font-size="26"
font-family="Fraunces, serif" font-weight="700">
${value.toFixed(1)}°
</text>
<text x=${cx} y=${cy + 15} text-anchor="middle"
fill=${readoutMutedColor} font-size="6.5"
font-family="JetBrains Mono, monospace" letter-spacing="2">
UTCI NOW
</text>
<text x=${cx} y=${cy + 23} text-anchor="middle"
fill=${glowColor} font-size="7"
font-family="JetBrains Mono, monospace" letter-spacing="0.8">
${cat.label.toUpperCase()}
</text>
</>`
: html`<text x=${cx} y=${cy + 5} text-anchor="middle"
fill=${readoutMutedColor} font-size="8"
font-family="JetBrains Mono, monospace" letter-spacing="1.2">
AWAITING
</text>`}
</svg>`;
}
// ════════════════════════════════════════════════════════════════════════
// MAIN COMPONENT — this is what the page renders.
// ════════════════════════════════════════════════════════════════════════
// Everything below is one big function. Reading order:
//
// 1. STATE (useState calls) — the bits that change as
// the user clicks around.
// 2. EFFECTS (useEffect calls) — code that runs when
// something changes
// (search input, location).
// 3. COMPUTATION (hourlyRows, days, …) — turns raw API data into
// rows ready to display.
// 4. JSX RETURN (the big html`...`) — the actual page markup.
// ════════════════════════════════════════════════════════════════════════
function UTCIForecast() {
// ── 1. STATE ──────────────────────────────────────────────────────────
// Each useState() pairs a value with a setter. Calling the setter
// re-renders the page with the new value.
// The location we're forecasting for. Change the values below to set
// a different starting location for new visitors.
const [location, setLocation] = useState({
name: 'Pangbourne, Berkshire',
lat: 51.4839,
lon: -1.0725,
country: 'GB',
});
const [forecast, setForecast] = useState(null); // raw Open-Meteo response
const [loading, setLoading] = useState(false); // true while fetching
const [error, setError] = useState(null); // fetch error message
const [searchQuery, setSearchQuery] = useState(''); // text in the search box
const [searchResults, setSearchResults] = useState([]); // geocoding dropdown
const [searching, setSearching] = useState(false); // search-in-flight flag
const [selectedDay, setSelectedDay] = useState(0); // which day tab is active
const [proPromptDay, setProPromptDay] = useState(null); // locked day clicked → show upsell card
// Day-tabs horizontal scrolling — chevrons show only when there's more
// content to reveal in that direction. Auto-scrolls active tab into view.
const dayTabsRef = useRef(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Re-runs whenever the number of day tabs changes (e.g. when the
// forecast finishes loading and the tabs first appear). Also re-measures
// on scroll, on window resize, and via ResizeObserver if the element's
// own width changes (e.g. layout shifts when sidebar opens).
useEffect(() => {
const el = dayTabsRef.current;
if (!el) return;
const update = () => {
setCanScrollLeft(el.scrollLeft > 1);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
update();
el.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
let ro = null;
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(update);
ro.observe(el);
}
return () => {
el.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
if (ro) ro.disconnect();
};
}, [forecast]);
useEffect(() => {
const el = dayTabsRef.current;
if (!el) return;
const activeTab = el.querySelector('.utci-day-tab.active');
if (!activeTab) return;
const elRect = el.getBoundingClientRect();
const tabRect = activeTab.getBoundingClientRect();
if (tabRect.left < elRect.left + 8) {
el.scrollBy({ left: tabRect.left - elRect.left - 24, behavior: 'smooth' });
} else if (tabRect.right > elRect.right - 8) {
el.scrollBy({ left: tabRect.right - elRect.right + 24, behavior: 'smooth' });
}
}, [selectedDay]);
const scrollDayTabs = (dir) => {
const el = dayTabsRef.current;
if (!el) return;
el.scrollBy({ left: dir * 200, behavior: 'smooth' });
};
// ─── PRO TIER STUB ────────────────────────────────────────────────────
// FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE.
// When this is wired to real billing/auth, replace `useState(false)`
// with a check against the logged-in user.
const [isPro, setIsPro] = useState(true);
// How many days the free tier shows. Days beyond this get a 🔒.
// Bump this number if you want to give free users more access.
const FREE_DAYS = 3;
// Which columns appear in the hourly table by default.
// true = visible on first load (and the only ones free users see)
// false = hidden by default (Pro users can toggle these on)
const [visibleCols, setVisibleCols] = useState({
hour: true, air: true, rh: false, dew: false,
wind: true, dir: false,
cloud: false, sun: false, direct: false, diffuse: false,
tmrt: false, delta: false, utci: false,
uvA: false, uvB: false, burn: false,
utciP: true, precip: true,
soilT: false, soilT6: false, soilM: false,
});
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
// Skin type for the sunburn-time column. Fitzpatrick II is typical UK fair.
const [skinType, setSkinType] = useState('II');
const searchTimeout = useRef(null);
// Refs for the two-scroller table layout (sticky-to-viewport header +
// horizontally-scrolling body). The header is clipped (overflow:hidden)
// and its inner "track" gets translateX'd via JS to follow the body's
// scrollLeft. See the useLayoutEffect just below where the JS sync
// happens, and the .utci-thead-sticky / .utci-tbody-scroll CSS rules.
const headStickyRef = useRef(null);
const headTrackRef = useRef(null);
const headTableRef = useRef(null);
const bodyScrollRef = useRef(null);
const bodyTableRef = useRef(null);
// ─── TABLE SCROLL SYNC ───────────────────────────────────────────────
// The hourly table is rendered as two stacked scroll areas:
// • Sticky header strip (locked to viewport top, clipped)
// • Body scroller (overflow-x: auto — owns the horizontal scrollbar)
// We need to (a) keep the header track shifted horizontally to match
// the body's scrollLeft, and (b) keep the header cells the same pixel
// width as the body cells even as columns toggle or the window resizes.
// ─────────────────────────────────────────────────────────────────────
const handleBodyScroll = () => {
const track = headTrackRef.current;
const body = bodyScrollRef.current;
if (!track || !body) return;
track.style.transform = `translate3d(${-body.scrollLeft}px, 0, 0)`;
};
useLayoutEffect(() => {
// Synchronise the head and body table column widths with a
// "shrink-to-fit then distribute" strategy:
// • Measure each column's true natural (content-fit) width by
// temporarily switching both tables to table-layout: auto +
// width: max-content. White-space: nowrap on cells stops content
// from wrapping, so the measurement is the smallest width that
// won't clip the content.
// • If the body scroller has spare horizontal space (natural total
// < container width), scale every column up proportionally to
// fill it — so toggling columns off makes the remaining ones fan
// out instead of leaving an awkward gap.
// • Otherwise apply the natural widths as-is and let the body
// scroller's overflow-x: auto produce a horizontal scrollbar.
const sync = () => {
const headTable = headTableRef.current;
const bodyTable = bodyTableRef.current;
const bodyScroll = bodyScrollRef.current;
if (!headTable || !bodyTable || !bodyScroll) return;
const bodyRow = bodyTable.querySelector('tbody tr');
const headRow = headTable.querySelector('thead tr');
if (!bodyRow || !headRow) return;
const headCells = Array.from(headRow.children);
const bodyCells = Array.from(bodyRow.children);
const n = Math.min(headCells.length, bodyCells.length);
if (n === 0) return;
// Step 1: clear any previously-forced cell widths and switch the
// tables to natural sizing so the measurement reflects the true
// content-fit width — independent of how wide the container is.
headCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
headTable.style.width = 'max-content';
bodyTable.style.width = 'max-content';
headTable.style.tableLayout = 'auto';
bodyTable.style.tableLayout = 'auto';
// Step 2: read each cell's natural width. getBoundingClientRect
// forces synchronous layout — that's what we want.
const naturalW = new Array(n);
let naturalTotal = 0;
for (let i = 0; i < n; i++) {
const headW = headCells[i].getBoundingClientRect().width;
const bodyW = bodyCells[i].getBoundingClientRect().width;
const w = Math.max(Math.ceil(headW), Math.ceil(bodyW));
naturalW[i] = w;
naturalTotal += w;
}
// Step 3: decide final widths based on available container width.
const containerW = bodyScroll.clientWidth;
const finalW = new Array(n);
let totalWidth;
if (naturalTotal > 0 && naturalTotal < containerW) {
// Spare space — distribute proportionally across columns so they
// fan out to fill the scroller (no awkward right-hand gap).
const scale = containerW / naturalTotal;
let running = 0;
for (let i = 0; i < n - 1; i++) {
finalW[i] = Math.floor(naturalW[i] * scale);
running += finalW[i];
}
// Absorb sub-pixel rounding into the last column so the total
// exactly matches the container width.
finalW[n - 1] = containerW - running;
totalWidth = containerW;
} else {
// Naturals don't fit — use them as-is and let the body scroll.
for (let i = 0; i < n; i++) finalW[i] = naturalW[i];
totalWidth = naturalTotal;
}
// Step 4: restore the CSS-defined table-layout: fixed so the
// explicit cell widths we apply below are honoured by the browser
// (not redistributed by the auto-layout algorithm).
headTable.style.tableLayout = '';
bodyTable.style.tableLayout = '';
// Step 5: apply the final width to both head and body cells.
for (let i = 0; i < n; i++) {
const px = `${finalW[i]}px`;
headCells[i].style.width = px;
headCells[i].style.minWidth = px;
headCells[i].style.maxWidth = px;
bodyCells[i].style.width = px;
bodyCells[i].style.minWidth = px;
bodyCells[i].style.maxWidth = px;
}
// Make both tables exactly totalWidth wide so they share the same
// horizontal extent — column N in the header sits directly above
// column N in the body, no drift as you scroll right.
headTable.style.width = `${totalWidth}px`;
bodyTable.style.width = `${totalWidth}px`;
// Re-apply current horizontal offset so column alignment survives.
handleBodyScroll();
};
// Run once after layout
sync();
// Re-sync when the scroll container's width changes (window resize,
// sidebar opens, etc). We observe the scroller — not the body table —
// because the body table's width is now driven by sync itself, which
// would otherwise create a feedback loop.
let ro = null;
if (typeof ResizeObserver !== 'undefined' && bodyScrollRef.current) {
ro = new ResizeObserver(sync);
ro.observe(bodyScrollRef.current);
}
window.addEventListener('resize', sync);
return () => {
if (ro) ro.disconnect();
window.removeEventListener('resize', sync);
};
}, [forecast, visibleCols, selectedDay, skinType]);
// 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 ──────────────────────────────────────────────────
// Runs every time `location` changes (i.e. when a new city is picked).
// Builds the Open-Meteo URL and stores the response in `forecast`.
// Change forecast_days=14 below to fetch a different range (max 16).
// Add or remove fields in the `&hourly=...` list to fetch more data —
// but if you remove one that's used elsewhere, expect errors.
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,dew_point_2m,` +
`wind_speed_10m,wind_direction_10m,wind_gusts_10m,` +
`direct_radiation,diffuse_radiation,shortwave_radiation,` +
`cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` +
`uv_index,precipitation,snowfall,` +
`soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
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]);
// ─── COMPUTATION ─────────────────────────────────────────────────────
// Take the raw API arrays and stitch them into one object per hour,
// calculating UTCI + soak-factor for each row. This is what gets
// displayed in the table.
const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => {
const h = forecast.hourly;
const Ta = h.temperature_2m[i];
const RH = h.relative_humidity_2m[i];
const dew = h.dew_point_2m ? h.dew_point_2m[i] : null;
const va = h.wind_speed_10m[i];
const wd = h.wind_direction_10m ? h.wind_direction_10m[i] : null;
const gust = h.wind_gusts_10m ? h.wind_gusts_10m[i] : null;
const dir = h.direct_radiation[i] || 0;
const dif = h.diffuse_radiation[i] || 0;
const glob = h.shortwave_radiation[i] || 0;
const cc = h.cloud_cover[i];
const ccLow = h.cloud_cover_low ? h.cloud_cover_low[i] : null;
const ccMid = h.cloud_cover_mid ? h.cloud_cover_mid[i] : null;
const ccHigh = h.cloud_cover_high ? h.cloud_cover_high[i] : null;
const uv = h.uv_index ? (h.uv_index[i] || 0) : 0;
const precip = h.precipitation[i] || 0;
const snow = h.snowfall[i] || 0;
const soilT0 = h.soil_temperature_0cm ? h.soil_temperature_0cm[i] : null;
const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null;
const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null;
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);
// Derived
const compass = windCompass8(wd);
const { uvA, uvB } = uvSplit(uv, elev);
const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh);
return {
iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob,
cc, ccLow, ccMid, ccHigh, cloudCat,
uv, uvA, uvB,
precip, snow,
soilT0, soilT6, soilM,
elev, Tmrt, utci, utciAdj, eh, compass,
};
}) : [];
// Group those hourly rows into days for the day tabs.
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 || [];
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' };
// ─── 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`
<div class="utci-app">
<nav class="utci-topnav">
<a href="./index.html">Home</a>
<a href="./about.html">About</a>
</nav>
<div class="lens-bloom-a"></div>
<div class="lens-bloom-b"></div>
<div class="utci-shell">
<div class="utci-header">
<div>
<h1 class="utci-title">
<span class="title-sun">SUN</span><span class="title-scope">Scope</span>
<sub class="title-beta">beta</sub>
</h1>
<div class="utci-tagline">See the sun the way your body does.</div>
<div class="utci-subtitle">
<a href="https://utci.org/" target="_blank" rel="noopener noreferrer" class="utci-subtitle-link">Universal Thermal Climate Index</a>
· Bröde 2012 · Open-Meteo · SunScope soak-factor
</div>
<div class="utci-current-loc">
${location.name}
<span class="utci-loc-coords">
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
</span>
</div>
</div>
<div>
<${ScopeReticle}
value=${currentRow?.utciAdj ?? null}
cat=${currentCat}
loading=${loading}
elev=${currentRow?.elev ?? 0}
dt=${currentRow?.dt ?? new Date()}
/>
</div>
<div class="header-right">
<div class="utci-search-wrap">
<label class="utci-search-label">Change location</label>
<input
class="utci-search"
type="text"
placeholder="Search any town or city…"
value=${searchQuery}
onInput=${(e) => setSearchQuery(e.currentTarget.value)}
/>
${searchResults.length > 0 && html`
<div class="utci-results">
${searchResults.map((r) => html`
<div
key=${`${r.id}-${r.latitude}`}
class="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 class="utci-result-meta">
${r.country} · ${r.latitude.toFixed(2)}°, ${r.longitude.toFixed(2)}°
</div>
</div>`)}
</div>`}
${searching && html`<div class="utci-searching">Searching…</div>`}
</div>
</div>
</div>
${error && html`
<div class="utci-status" style=${{ borderColor: '#3a1010', color: '#c44a3a' }}>
${error}
</div>`}
${loading && !error && html`
<div class="utci-status">Acquiring forecast data…</div>`}
${forecast && days.length > 0 && html`
<${Fragment}>
<!--
DAY TABS — one button per day, coloured by confidence band.
Days 4+ get 🔒'd when isPro is false. To change the lock
behaviour (e.g. open a paywall modal instead of doing
nothing), edit the onClick handler below.
-->
<div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}>
<button
class=${`utci-day-scroll left${canScrollLeft ? '' : ' hidden'}`}
onClick=${() => scrollDayTabs(-1)}
aria-label="Scroll days left"
type="button"></button>
<button
class=${`utci-day-scroll right${canScrollRight ? '' : ' hidden'}`}
onClick=${() => scrollDayTabs(1)}
aria-label="Scroll days right"
type="button"></button>
<div class="utci-day-tabs" ref=${dayTabsRef}>
${days.map((d, i) => {
const band = confidenceBand(i);
const locked = !isPro && i >= FREE_DAYS;
const isActive = i === selectedDay;
const dayName = i === 0 ? 'Today'
: i === 1 ? 'Tomorrow'
: d.date.toLocaleDateString('en-GB', { weekday: 'short' });
return html`
<button
key=${d.key}
class=${`utci-day-tab ${isActive ? 'active' : ''}${locked ? ' locked' : ''}`}
onClick=${() => {
if (locked) {
setProPromptDay(i); // show the upsell card
} else {
setSelectedDay(i);
setProPromptDay(null); // hide the card on a normal click
}
}}
title=${locked
? `${band.label} · SunScope Pro unlocks day ${i + 1}`
: `${band.label} · day ${i + 1} of 14`}
style=${{
background: band.bg,
color: '#2a1d10',
borderStyle: 'solid',
borderWidth: '0 0 3px 0',
borderBottomColor: isActive ? '#1e1208' : band.edge,
opacity: locked ? 0.5 : 1,
cursor: locked ? 'not-allowed' : 'pointer',
position: 'relative',
filter: isActive ? 'saturate(1.15) brightness(1.02)' : 'none',
}}
>
${locked && html`
<span style=${{ position: 'absolute', top: '3px', right: '5px', fontSize: '10px', opacity: 0.75 }}>🔒</span>`}
${dayName}
<span class="utci-day-date" style=${{ color: '#5a3f24' }}>
${d.date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
</span>
</button>`;
})}
</div>
</div>
<!--
PRO UPSELL CARD shown when a locked day is clicked.
Visible only while proPromptDay !== null. To change the
copy or pricing, edit the strings below. The "Notify me"
button is a mailto: link replace with a real signup
form when you have one.
-->
${proPromptDay !== null && days[proPromptDay] && (() => {
const promptDate = days[proPromptDay].date;
const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long' });
const dayLong = promptDate.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' });
return html`
<div style=${{
margin: '12px 0',
padding: '18px 22px',
background: '#fdf8ee',
border: '1.5px solid #c9b08a',
borderLeft: '4px solid #c8922a',
borderRadius: '0 4px 4px 0',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '14px',
}}>
<div style=${{ flex: '1 1 320px', minWidth: '260px' }}>
<div style=${{
fontFamily: 'Fraunces, serif',
fontStyle: 'italic',
fontSize: '19px',
fontWeight: 700,
color: '#1e1208',
marginBottom: '6px',
lineHeight: 1.25,
}}>
🔒 ${dayName}'s forecast is part of SunScope Pro
</div>
<div style=${{
fontFamily: 'Manrope, sans-serif',
fontSize: '13.5px',
color: '#4a3420',
lineHeight: 1.65,
}}>
Pro unlocks the full 14-day forecast, customisable columns,
and an ad-free view.
</div>
<div style=${{
fontFamily: 'JetBrains Mono, monospace',
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.14em',
color: '#c8922a',
marginTop: '10px',
}}>
£2 / month · launching soon
</div>
</div>
<div style=${{
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignItems: 'flex-end',
}}>
<a
href=${`mailto:fraxle@yahoo.co.uk?subject=${encodeURIComponent('SunScope Pro — notify me at launch')}&body=${encodeURIComponent('Hi — please let me know when SunScope Pro launches. (Triggered by ' + dayLong + ')')}`}
style=${{
display: 'inline-block',
padding: '10px 18px',
background: '#c8922a',
color: '#fff',
textDecoration: 'none',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.14em',
borderRadius: '3px',
whiteSpace: 'nowrap',
}}
>
Notify me at launch
</a>
<button
onClick=${() => setProPromptDay(null)}
style=${{
background: 'transparent',
border: 'none',
cursor: 'pointer',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.14em',
color: '#b09870',
padding: '2px 4px',
}}
>
dismiss
</button>
</div>
</div>`;
})()}
${(() => {
const band = confidenceBand(selectedDay);
const isOutlook = selectedDay >= 7;
return html`
<div style=${{
display: 'flex',
alignItems: 'center',
gap: '10px',
margin: '8px 0 4px',
padding: '6px 10px',
background: band.tint,
borderLeft: `3px solid ${band.edge}`,
borderRadius: '0 4px 4px 0',
fontSize: '12px',
color: '#3a2a18',
fontFamily: 'JetBrains Mono, monospace',
flexWrap: 'wrap',
}}>
<span style=${{ letterSpacing: '1px', textTransform: 'uppercase', fontWeight: 700 }}>
Day ${selectedDay + 1} of 14 · Forecast clarity: ${band.label}
</span>
${isOutlook && html`
<span style=${{ opacity: 0.7, fontStyle: 'italic' }}>
forecast skill is reduced — treat hourly detail as trend, not precision
</span>`}
</div>`;
})()}
<!--
COLUMN TOGGLES only shown to Pro users.
Free users see the "default columns" note instead. To add
a new toggleable column, add an entry both here AND in the
visibleCols useState() near the top of this component.
-->
${isPro
? html`
<div class="col-toggles">
<span class="col-toggles-label">Columns:</span>
${[
// Hour and UTCI+P are always-on — no toggle button for them.
{ key: 'air', label: 'Air' },
{ key: 'rh', label: 'RH' },
{ key: 'dew', label: 'Dew' },
{ key: 'soilT', label: 'Soil °C' },
{ key: 'soilT6', label: 'Soil 6cm' },
{ key: 'soilM', label: 'Soil moist' },
{ key: 'wind', label: 'Wind' },
{ key: 'dir', label: 'Dir' },
{ 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: 'uvA', label: 'UV-A' },
{ key: 'uvB', label: 'UV-B' },
{ key: 'burn', label: 'Burn' },
{ key: 'precip', label: 'Precip' },
].map(c => html`
<button
key=${c.key}
class=${`col-toggle${visibleCols[c.key] ? ' on' : ''}`}
onClick=${() => toggleCol(c.key)}
>${c.label}</button>`)}
${visibleCols.burn && html`
<span style=${{ marginLeft: '10px', display: 'inline-flex', alignItems: 'center', gap: '6px', fontFamily: 'JetBrains Mono, monospace', fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.08em', color: '#9a7d5a' }}>
Skin
<select
value=${skinType}
onChange=${(e) => setSkinType(e.target.value)}
style=${{ background: '#2a1f12', color: '#ede4cc', border: '1px solid #5a4228', borderRadius: '3px', padding: '2px 4px', fontFamily: 'JetBrains Mono, monospace', fontSize: '10px' }}>
${Object.entries(SKIN_TYPES).map(([k, v]) => html`
<option key=${k} value=${k}>${v.name}</option>`)}
</select>
</span>`}
</div>`
: html`
<div class="col-toggles" style=${{ opacity: 0.85 }}>
<span class="col-toggles-label">Default columns</span>
<span style=${{ marginLeft: '8px', color: '#9a7d5a', fontFamily: 'JetBrains Mono, monospace', fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
customisable columns &amp; days 414 are part of SunScope Pro
</span>
</div>`}
<!--
HOURLY TABLE each row is one hour from the selected day.
Each column is wrapped in a visibleCols.X check, so it only
shows when its toggle is on. To force a column to always
show, remove the visibleCols check around it. To rename a
heading, edit the text inside the matching <th>.
-->
<div class="utci-table-wrap">
<!-- Sticky header strip locks to viewport top. Clipped
horizontally; the inner .utci-thead-track is shifted
via translateX from JS to follow the body's scrollLeft.
See handleBodyScroll + useLayoutEffect above. -->
<div class="utci-thead-sticky" ref=${headStickyRef}>
<div class="utci-thead-track" ref=${headTrackRef}>
<table class="utci-table utci-table-head" ref=${headTableRef}>
<thead>
<tr>
<th>Hour</th>
${visibleCols.air && html`<th class="utci-tight-head">Air <span class="col-unit">°C</span></th>`}
${visibleCols.rh && html`<th>RH <span class="col-unit">%</span></th>`}
${visibleCols.dew && html`<th>Dew <span class="col-unit">°C</span></th>`}
${visibleCols.soilT && html`<th>Soil °C <span class="col-unit">surface</span></th>`}
${visibleCols.soilT6 && html`<th>Soil 6cm <span class="col-unit">°C root</span></th>`}
${visibleCols.soilM && html`<th>Soil moist <span class="col-unit">m³/m³</span></th>`}
${visibleCols.wind && html`<th>Wind <span class="col-unit">m/s (gust)</span></th>`}
${visibleCols.dir && html`<th class="utci-dir-cell">Dir <span class="col-unit">-</span></th>`}
${visibleCols.cloud && html`<th>Cloud <span class="col-unit">%</span></th>`}
${visibleCols.sun && html`<th>Sun <span class="col-unit">elev°</span></th>`}
${visibleCols.direct && html`<th>Direct <span class="col-unit">W/m²</span></th>`}
${visibleCols.diffuse && html`<th>Diffuse <span class="col-unit">W/m²</span></th>`}
${visibleCols.tmrt && html`<th>Tmrt <span class="col-unit">°C</span></th>`}
${visibleCols.delta && html`<th>Δ <span class="col-unit">UTCIAir</span></th>`}
${visibleCols.utci && html`<th>UTCI <span class="col-unit">°C felt</span></th>`}
${visibleCols.uvA && html`<th>UV-A <span class="col-unit">est. idx</span></th>`}
${visibleCols.uvB && html`<th>UV-B <span class="col-unit">est. idx</span></th>`}
${visibleCols.burn && html`<th>Burn <span class="col-unit">to MED</span></th>`}
${visibleCols.precip && html`<th class="utci-tight-head">Pcpt <span class="col-unit">mm/h</span></th>`}
<th>UTCI+P <span class="col-unit">°C adj.</span></th>
</tr>
</thead>
</table>
</div>
</div>
<!-- Body scroller — owns the horizontal scrollbar. The
onScroll handler translates the header track to keep
columns aligned with the visible body columns. -->
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
<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;
const adjCat = utciCategory(r.utciAdj);
return html`
<tr key=${r.iso}
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
<td class="utci-time">
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
<${SkyScope} elev=${r.elev} dt=${r.dt} size=${30} />
<span>${r.dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}</span>
</span>
</td>
${visibleCols.air && html`<td>${r.Ta.toFixed(1)}</td>`}
${visibleCols.rh && html`<td>${Math.round(r.RH)}</td>`}
${visibleCols.dew && html`<td>${r.dew != null ? r.dew.toFixed(1) : ''}</td>`}
${visibleCols.soilT && html`
<td style=${{ color: '#8a6a3a' }}>${r.soilT0 != null ? r.soilT0.toFixed(1) : ''}</td>`}
${visibleCols.soilT6 && html`
<td style=${{ color: '#8a6a3a' }}>${r.soilT6 != null ? r.soilT6.toFixed(1) : ''}</td>`}
${visibleCols.soilM && html`
<td style=${{ color: '#5090b0' }}>${r.soilM != null ? r.soilM.toFixed(3) : ''}</td>`}
${visibleCols.wind && html`<td>
${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5
? html`<span style=${{ opacity: 0.65, marginLeft: '4px' }}>(${r.gust.toFixed(1)})</span>`
: ''}
</td>`}
${visibleCols.dir && html`<td class="utci-dir-cell">
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
<${WindVane} bearing=${r.wd} size=${28} />
<span class="wind-dir-label" style=${{ fontFamily: 'JetBrains Mono, monospace', fontSize: '11px' }}>${r.compass.label}</span>
</span>
</td>`}
${visibleCols.cloud && html`<td>
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} />
<span>${Math.round(r.cc)}</span>
</span>
</td>`}
${visibleCols.sun && html`<td>${r.elev > 0 ? r.elev.toFixed(1) : ''}</td>`}
${visibleCols.direct && html`<td>${Math.round(r.dir)}</td>`}
${visibleCols.diffuse && html`<td>${Math.round(r.dif)}</td>`}
${visibleCols.tmrt && html`<td>${r.Tmrt.toFixed(1)}</td>`}
${visibleCols.delta && html`
<td style=${{
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#9a7d5a',
fontWeight: 600,
}}>
${delta > 0 ? '+' : ''}${delta.toFixed(1)}
</td>`}
${visibleCols.utci && html`
<td>
<span class="utci-cell" style=${{ background: cat.bg, color: cat.fg }}>
${r.utci.toFixed(1)}
</span>
</td>`}
${visibleCols.uvA && html`
<td style=${{ color: r.uvA > 0 ? '#c8922a' : '#5a4228' }}>
${r.uvA > 0 ? r.uvA.toFixed(1) : ''}
</td>`}
${visibleCols.uvB && html`
<td style=${{ color: r.uvB > 0 ? '#c44a3a' : '#5a4228', fontWeight: 600 }}>
${r.uvB > 0 ? r.uvB.toFixed(2) : ''}
</td>`}
${visibleCols.burn && html`
<td style=${{ color: r.uv > 0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#5a4228' }}>
${burnLabel(sunburnMinutes(r.uv, skinType))}
</td>`}
${visibleCols.precip && html`
<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>`}
<td style=${{ background: 'rgba(180,215,250,0.10)' }}>
<span class="utci-cell utci-cell-hero" style=${{ background: adjCat.bg, color: adjCat.fg }}>
${r.utciAdj.toFixed(1)}
</span>
</td>
</tr>`;
})}
</tbody>
</table>
</div>
</div>
<div class="utci-legend">
<span class="utci-legend-label">Thermal stress bands</span>
<div class="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) => html`
<span key=${i} class="utci-legend-item" style=${{ background: b.bg, color: b.fg }}>
${b.l}
</span>`)}
</div>
</div>
</>`}
<div class="utci-about">
<h2 class="utci-about-heading">What is SunScope?</h2>
<p class="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 class="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 an honest, real-world comfort score for any
location worldwide — simply search for your town or city and compare the hourly
forecast across the next 3 days (and up to 14 days with SunScope Pro).
</p>
</div>
<div class="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>`;
}
// ════════════════════════════════════════════════════════════════════════
// MOUNT — find the empty <div id="root"></div> in index.html and render
// the whole UTCIForecast component into it. This is the very last thing
// the script does. If nothing appears on the page, check that:
// 1. index.html contains <div id="root"></div>
// 2. index.html loads this file as <script type="module" src="...">
// 3. The browser console (F12) doesn't show an error above this line.
// ════════════════════════════════════════════════════════════════════════
const rootEl = document.getElementById('root');
if (rootEl) render(h(UTCIForecast, null), rootEl);