New data columns for UV, soil and wind direction. Add icons to some data Better table formatting and responsiveness Improved scope background variations
1756 lines
94 KiB
JavaScript
1756 lines
94 KiB
JavaScript
// ════════════════════════════════════════════════════════════════════════
|
||
// 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 — Stefan–Boltzmann 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 1–3 highest skill
|
||
: i < 7 ? 'Sharp' // days 4–7 solid
|
||
: i < 10 ? 'Soft focus' // days 8–10 trends only
|
||
: 'Blurry', // days 11–14 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 + 7} 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 + 19} 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: true, dew: true,
|
||
wind: true, dir: true,
|
||
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.
|
||
// Each column is sized to max(60px, header's natural width, body's
|
||
// natural width) so neither header labels nor body data ever feel
|
||
// squashed, and header text never overflows into the next column.
|
||
const MIN_COL = 60;
|
||
const sync = () => {
|
||
const headTable = headTableRef.current;
|
||
const bodyTable = bodyTableRef.current;
|
||
if (!headTable || !bodyTable) 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);
|
||
// Step 1: clear any previously-forced widths so we can read each
|
||
// cell's *natural* width (the width it'd take with just min-width
|
||
// and content driving it).
|
||
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 = '';
|
||
// Reading getBoundingClientRect forces layout — that's what we want.
|
||
// Step 2: compute final widths as max(MIN_COL, headNatural, bodyNatural).
|
||
const finalW = new Array(n);
|
||
let totalWidth = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
const headW = headCells[i].getBoundingClientRect().width;
|
||
const bodyW = bodyCells[i].getBoundingClientRect().width;
|
||
const w = Math.max(MIN_COL, Math.ceil(headW), Math.ceil(bodyW));
|
||
finalW[i] = w;
|
||
totalWidth += w;
|
||
}
|
||
// Step 3: 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 the header table the same total width as the body table
|
||
// so the inner track has somewhere to translate to.
|
||
headTable.style.width = `${totalWidth}px`;
|
||
// Re-apply current horizontal offset so column alignment survives.
|
||
handleBodyScroll();
|
||
};
|
||
// Run once after layout
|
||
sync();
|
||
// Re-sync if the body table reflows (columns toggle, content changes)
|
||
let ro = null;
|
||
if (typeof ResizeObserver !== 'undefined' && bodyTableRef.current) {
|
||
ro = new ResizeObserver(sync);
|
||
ro.observe(bodyTableRef.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>
|
||
${[
|
||
{ key: 'hour', label: 'Hour' },
|
||
{ 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' },
|
||
{ key: 'utciP', label: 'UTCI+P' },
|
||
].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 & days 4–14 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>
|
||
${visibleCols.hour && html`<th>Hour</th>`}
|
||
${visibleCols.air && html`<th>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>Dir <span class="col-unit">compass</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">UTCI−Air</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>Precip <span class="col-unit">mm/h</span></th>`}
|
||
${visibleCols.utciP && html`<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()}>
|
||
${visibleCols.hour && html`
|
||
<td class="utci-time">
|
||
${isNow && html`<span class="now-pip"></span>`}
|
||
<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>
|
||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
|
||
<${WindVane} bearing=${r.wd} size=${28} />
|
||
<span 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>`}
|
||
${visibleCols.utciP && html`
|
||
<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
|
||
1–2 °C; heavy rain combined with wind can push it down by 7–8 °C. Snow carries an
|
||
additional penalty on top. The result is 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);
|