Beta2 Updates

Now 14 Days
Accuracy Indicators
Sunrise/Sunset
Moon Cycles
Early Pro Limitations
New Code Formatting
This commit is contained in:
fraxle
2026-05-11 11:42:32 +01:00
parent 2e1f3b7c48
commit cce78feea7
8 changed files with 1961 additions and 877 deletions
File diff suppressed because one or more lines are too long
+720
View File
@@ -0,0 +1,720 @@
/* ════════════════════════════════════════════════════════════════════════
SUNSCOPE · styles
════════════════════════════════════════════════════════════════════════
QUICK MAP — where to find and change common things
──────────────────────────────────────────────────────────────────────
Page background body { background }
Body text colour .utci-app { color }
Brand "brass" colour search file for #c8922a
Headline font .utci-title { font-family }
Body text font .utci-app { font-family }
Max page width .utci-shell { max-width }
Mobile breakpoint @media (max-width: 800px) at the bottom
Big UTCI dial size .scope-ring { width / height }
COLOUR PALETTE (the "warm parchment & brass" theme)
──────────────────────────────────────────────────────────────────────
#f5edd6 parchment page background
#fffcf2 soft cream cards, search box, table
#fdf8ee warm cream toolbars, legend, about block
#f0e4c4 pale honey table heading row
#fef3dc warm hover buttons on hover
#fef8ea row hover table row on hover
#fff8e4 "now" row the currently-active hour
#1e1208 espresso headings + emphasis
#4a3420 warm dark body text in the table
#6b4f2a muted bronze subtle emphasis
#7a5c2a tagline brown italic intro line
#9a7d5a soft brown column toggle text
#b09870 warm grey muted labels
#c0a880 pale dust night-row text, footer
#c8922a BRASS brand accent (links, dial, focus)
#a06e18 deep brass hover state for some links
#c9b08a warm border panel borders
#d4c0a0 soft border toolbar/legend borders
#ede4cc line table row dividers
FONTS
──────────────────────────────────────────────────────────────────────
Fraunces serif display titles, day labels, hour cell
Manrope body sans-serif paragraphs
JetBrains Mono numeric monospace table data, small labels
FILE STRUCTURE (in order below)
──────────────────────────────────────────────────────────────────────
1. Fonts + reset
2. Page background & shell
3. Top navigation
4. Header (title + dial + search)
5. Day tabs
6. Column toggles
7. Hourly table
8. Now-row + night-row decorations
9. Status & error banners
10. Legend
11. About + footer
12. Responsive (mobile)
════════════════════════════════════════════════════════════════════════ */
/* ── 1. FONTS + RESET ───────────────────────────────────────────────── */
@import "https://fonts.bunny.net/css2?family=Fraunces:opsz,wght@9..144,300;9..144,500;9..144,700;9..144,900&family=JetBrains+Mono:wght@400;600&family=Manrope:wght@400;500;600;700&display=swap";
*,
*:before,
*:after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── 2. PAGE BACKGROUND & SHELL ─────────────────────────────────────── */
body {
background: #f5edd6; /* parchment */
}
.utci-app {
font-family: Manrope, sans-serif;
color: #1e1208;
background: #f5edd6;
min-height: 100vh;
padding: 52px 28px 32px;
position: relative;
overflow-x: hidden;
}
/* Two big soft glows in the corners — pure decoration.
Comment one out to remove that side's glow. */
.lens-bloom-a {
position: fixed;
top: -200px;
left: -200px;
width: 700px;
height: 700px;
border-radius: 50%;
background: radial-gradient(ellipse, rgba(255, 210, 60, 0.18) 0%, transparent 65%);
pointer-events: none;
z-index: 0;
}
.lens-bloom-b {
position: fixed;
top: -80px;
right: -140px;
width: 520px;
height: 420px;
background: radial-gradient(ellipse, rgba(200, 146, 42, 0.13) 0%, transparent 68%);
pointer-events: none;
z-index: 0;
}
/* The centred container that holds everything. */
.utci-shell {
position: relative;
z-index: 1;
max-width: 1400px;
margin: 0 auto;
}
/* ── 3. TOP NAVIGATION (Home / About links in the corner) ──────────── */
.utci-topnav {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 18px 32px;
gap: 28px;
}
.utci-topnav a {
font-family: JetBrains Mono, monospace;
font-size: 16px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #b09870;
text-decoration: none;
border-bottom: 1px solid transparent;
padding-bottom: 2px;
transition: color 0.2s, border-color 0.2s;
}
.utci-topnav a:hover {
color: #c8922a; /* brass on hover */
border-bottom-color: #c8922a80; /* faint underline */
}
/* ── 4. HEADER (title + dial + search, three-column grid) ──────────── */
.utci-header {
display: grid;
grid-template-columns: 1fr auto 1fr; /* title | dial | search */
gap: 32px;
align-items: center;
padding-bottom: 32px;
border-bottom: 2px solid #c9b08a;
margin-bottom: 32px;
}
.header-right {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
}
/* The big "SUNScope" wordmark */
.utci-title {
font-family: Fraunces, serif;
font-weight: 900;
font-variation-settings: "opsz" 144;
font-size: clamp(48px, 6vw, 80px); /* responsive — scales with viewport */
line-height: 0.9;
letter-spacing: -0.03em;
color: #1e1208;
}
.utci-title .title-sun { /* "SUN" — bold + brass */
color: #c8922a;
font-weight: 900;
font-style: normal;
}
.utci-title .title-scope { /* "Scope" — italic */
font-style: italic;
font-weight: 900;
color: #1e1208;
}
.utci-title .title-beta { /* the small "beta" subscript */
font-size: 0.38em;
text-transform: none;
font-weight: 400;
letter-spacing: 0.05em;
vertical-align: super;
color: #b09870;
}
/* "See the sun the way..." */
.utci-tagline {
font-family: Fraunces, serif;
font-style: italic;
font-size: 17px;
color: #7a5c2a;
margin-top: 14px;
}
/* The credits line under the tagline */
.utci-subtitle {
font-family: JetBrains Mono, monospace;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.18em;
margin-top: 10px;
color: #b09870;
}
.utci-subtitle-link { /* The "UTCI" link in the credits */
color: #c8922a;
text-decoration: none;
border-bottom: 1px solid rgba(200, 146, 42, 0.4);
transition: border-color 0.2s, color 0.2s;
}
.utci-subtitle-link:hover {
color: #a06e18;
border-bottom-color: #a06e18;
}
/* "↳ Pangbourne, Berkshire" */
.utci-current-loc {
font-family: Fraunces, serif;
font-style: italic;
font-size: 18px;
margin-top: 12px;
color: #1e1208;
}
/* The lat/lon next to the location */
.utci-loc-coords {
font-family: JetBrains Mono, monospace;
font-style: normal;
font-size: 11px;
color: #b09870;
margin-left: 10px;
letter-spacing: 0.08em;
}
/* The big UTCI dial in the middle of the header.
Change width/height to resize it. */
.scope-ring {
width: 220px;
height: 220px;
display: block;
}
/* The search box on the right of the header */
.utci-search-wrap {
position: relative;
width: 300px;
}
.utci-search-label {
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #b09870;
display: block;
margin-bottom: 7px;
}
.utci-search {
width: 100%;
padding: 12px 16px;
border: 1.5px solid #c9b08a;
background: #fffcf2;
font-family: Manrope, sans-serif;
font-size: 14px;
color: #1e1208;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.utci-search::placeholder {
color: #c0a880;
}
.utci-search:focus {
border-color: #c8922a;
box-shadow: 0 0 0 3px #c8922a24; /* brass glow when focused */
}
/* The dropdown list of city results */
.utci-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fffcf2;
border: 1.5px solid #c9b08a;
border-top: none;
max-height: 260px;
overflow-y: auto;
z-index: 50;
box-shadow: 0 8px 24px #6446141f;
}
.utci-result {
padding: 11px 16px;
cursor: pointer;
border-bottom: 1px solid #ede4cc;
font-size: 14px;
transition: background 0.12s;
border-left: 3px solid transparent;
}
.utci-result:hover {
background: #fef3dc;
border-left-color: #c8922a; /* brass marker on hover */
}
/* The "GB · 51.48°, -1.07°" line under each city */
.utci-result-meta {
font-size: 11px;
color: #b09870;
font-family: JetBrains Mono, monospace;
margin-top: 3px;
letter-spacing: 0.06em;
}
.utci-searching {
margin-top: 7px;
font-size: 10px;
color: #b09870;
font-family: JetBrains Mono, monospace;
letter-spacing: 0.14em;
text-transform: uppercase;
}
/* ── 5. DAY TABS (today / tomorrow / etc.) ─────────────────────────── */
/* Note: each tab's background colour comes from inline styles in
sunscope.js (the confidence bands). This file only handles spacing,
typography, and the active-tab underline. */
.utci-day-tabs {
display: flex;
margin-bottom: 18px;
border-bottom: 2px solid #d4c0a0;
}
.utci-day-tab {
padding: 12px 24px;
cursor: pointer;
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
background: transparent;
border: none;
color: #b09870;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.2s, border-color 0.2s;
}
.utci-day-tab:hover {
color: #6b4f2a;
}
.utci-day-tab.active {
color: #c8922a;
border-bottom-color: #c8922a;
}
/* The "Mon 12 May" date underneath each tab name */
.utci-day-date {
font-family: Fraunces, serif;
font-style: italic;
font-size: 13px;
text-transform: none;
letter-spacing: 0;
display: block;
margin-top: 3px;
color: inherit;
}
/* ── 6. COLUMN TOGGLES (the "Columns: Hour Air RH ..." bar) ────────── */
.col-toggles {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-bottom: 10px;
padding: 10px 14px;
background: #fdf8ee;
border: 1.5px solid #d4c0a0;
}
.col-toggles-label {
font-family: JetBrains Mono, monospace;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #b09870;
margin-right: 4px;
white-space: nowrap;
}
/* The little pill buttons */
.col-toggle {
padding: 4px 11px;
cursor: pointer;
border: 1.5px solid #c9b08a;
background: #f5edd6;
color: #9a7d5a;
font-family: JetBrains Mono, monospace;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.1em;
transition: all 0.15s;
-webkit-user-select: none;
user-select: none;
}
.col-toggle:hover {
border-color: #c8922a;
color: #6b4f2a;
background: #fef3dc;
}
/* The "pressed-in" active look */
.col-toggle.on {
background: #c8922a;
border-color: #c8922a;
color: #fff;
}
/* ── 7. HOURLY TABLE ────────────────────────────────────────────────── */
.utci-table-wrap {
overflow-x: auto;
border: 1.5px solid #c9b08a;
background: #fffcf2;
}
.utci-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
/* Column headings (Hour, Air, RH, Wind...) */
.utci-table th {
background: #f0e4c4;
color: #6b4f2a;
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
font-weight: 600;
padding: 14px 12px;
text-align: right;
white-space: nowrap;
position: sticky;
top: 0; /* sticks to the top while scrolling */
border-bottom: 2px solid #c9b08a;
}
/* The Hour column aligns left, the rest align right */
.utci-table th:first-child,
.utci-table td:first-child {
text-align: left;
}
/* The "°C" / "m/s" etc. unit underneath each heading */
.utci-table th .col-unit {
display: block;
color: #c8922a;
font-size: 9px;
margin-top: 2px;
}
.utci-table tbody tr {
border-bottom: 1px solid #ede4cc;
transition: background 0.1s;
}
.utci-table tbody tr:hover {
background: #fef8ea;
}
.utci-table td {
padding: 10px 12px;
text-align: right;
font-family: JetBrains Mono, monospace;
font-variant-numeric: tabular-nums; /* stops digits jiggling between rows */
white-space: nowrap;
color: #4a3420;
}
/* ── 8. NOW-ROW & NIGHT-ROW DECORATIONS ─────────────────────────────── */
/* "Currently this hour" — the highlighted row */
.utci-table tbody tr.is-now {
background: #fff8e4 !important;
box-shadow: inset 3px 0 #c8922a; /* brass stripe down the left */
}
.utci-table tbody tr.is-now td {
color: #1e1208;
}
/* The little brass pip in the time cell of the current hour */
.now-pip {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #c8922a;
margin-right: 7px;
vertical-align: middle;
box-shadow: 0 0 8px #c8922a73;
}
/* Night-time hours get a slightly muted text colour */
.utci-table tbody tr.is-night td {
color: #c0a880;
}
/* The time cell — uses the display serif instead of monospace */
.utci-time {
font-family: Fraunces, serif !important;
font-weight: 500;
font-size: 16px;
color: #1e1208 !important;
}
/* The coloured pill inside UTCI / UTCI+P cells.
The pill's BACKGROUND comes from inline styles in sunscope.js
(the utciCategory() function decides which thermal-stress colour
to apply). This rule just handles the pill shape. */
.utci-cell {
display: inline-block;
padding: 4px 12px;
border-radius: 3px;
font-weight: 600;
min-width: 60px;
text-align: center;
font-size: 14px;
}
.utci-band-label {
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* ── 9. STATUS & ERROR BANNERS ("Acquiring forecast data…") ────────── */
.utci-status {
padding: 14px 18px;
background: #f0e4c4;
color: #7a5c2a;
font-family: JetBrains Mono, monospace;
font-size: 11px;
text-align: center;
letter-spacing: 0.12em;
text-transform: uppercase;
border: 1.5px solid #c9b08a;
margin-bottom: 18px;
}
/* ── 10. LEGEND (thermal-stress band colours at the bottom) ────────── */
.utci-legend {
margin-top: 22px;
padding: 18px 20px;
background: #fdf8ee;
border: 1.5px solid #c9b08a;
}
.utci-legend-label {
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #b09870;
margin-bottom: 12px;
display: block;
font-weight: 600;
}
.utci-legend-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.utci-legend-item {
padding: 6px 12px;
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
flex: 1;
min-width: 110px;
text-align: center;
opacity: 0.9;
}
/* ── 11. ABOUT + FOOTER ─────────────────────────────────────────────── */
.utci-about {
margin-top: 28px;
padding: 24px 28px;
background: #fdf8ee;
border: 1.5px solid #c9b08a;
}
.utci-about-heading {
font-family: Fraunces, serif;
font-size: 18px;
font-weight: 700;
color: #1e1208;
margin-bottom: 14px;
letter-spacing: -0.01em;
}
.utci-about-text {
font-family: Manrope, sans-serif;
font-size: 14px;
line-height: 1.75;
color: #4a3420;
margin-bottom: 12px;
max-width: 860px; /* keeps line length readable */
}
.utci-about-text:last-child {
margin-bottom: 0;
}
.utci-about-text strong {
color: #1e1208;
font-weight: 600;
}
.utci-about-text em {
font-style: italic;
color: #6b4f2a;
}
.utci-footer {
margin-top: 36px;
padding-top: 20px;
border-top: 1px solid #d4c0a0;
font-family: JetBrains Mono, monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #c0a880;
line-height: 2.2;
}
/* The "Reading the table." intro snippet */
.utci-footer em {
font-family: Fraunces, serif;
font-style: italic;
text-transform: none;
letter-spacing: 0;
font-size: 14px;
color: #7a5c2a;
}
/* ── 12. RESPONSIVE (mobile) ───────────────────────────────────────── */
/* On screens narrower than 800px, stack the header vertically and
shrink the dial. Add more rules in this block to tweak mobile
layout further. */
@media (max-width: 800px) {
.utci-header {
grid-template-columns: 1fr; /* stack the 3 header columns */
text-align: center;
}
.header-right {
align-items: center;
}
.utci-search-wrap {
width: 100%;
}
.scope-ring {
width: 180px; /* smaller dial on mobile */
height: 180px;
margin: 0 auto;
}
}
+1234
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
var n=function(t,s,r,e){var u;s[0]=0;for(var h=1;h<s.length;h++){var p=s[h++],a=s[h]?(s[0]|=p?1:2,r[s[h++]]):s[++h];3===p?e[0]=a:4===p?e[1]=Object.assign(e[1]||{},a):5===p?(e[1]=e[1]||{})[s[++h]]=a:6===p?e[1][s[++h]]+=a+"":p?(u=t.apply(a,n(t,a,r,["",null])),e.push(u),a[0]?s[0]|=2:(s[h-2]=0,s[h]=u)):e.push(a)}return e},t=new Map;export default function(s){var r=t.get(this);return r||(r=new Map,t.set(this,r)),(r=n(this,r.get(s)||(r.set(s,r=function(n){for(var t,s,r=1,e="",u="",h=[0],p=function(n){1===r&&(n||(e=e.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?h.push(0,n,e):3===r&&(n||e)?(h.push(3,n,e),r=2):2===r&&"..."===e&&n?h.push(4,n,0):2===r&&e&&!n?h.push(5,0,!0,e):r>=5&&((e||!n&&5===r)&&(h.push(r,0,e,s),r=6),n&&(h.push(r,n,0,s),r=6)),e=""},a=0;a<n.length;a++){a&&(1===r&&p(),p(a));for(var l=0;l<n[a].length;l++)t=n[a][l],1===r?"<"===t?(p(),h=[h],r=3):e+=t:4===r?"--"===e&&">"===t?(r=1,e=""):e=t+e[0]:u?t===u?u="":e+=t:'"'===t||"'"===t?u=t:">"===t?(p(),r=1):r&&("="===t?(r=5,s=e,e=""):"/"===t&&(r<5||">"===n[a][l+1])?(p(),3===r&&(h=h[0]),r=h,(h=h[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(p(),r=2):e+=t),3===r&&"!--"===e&&(r=4,h=h[0])}return p(),h}(s)),r),arguments,[])).length>1?r:r[0]}
+2
View File
@@ -0,0 +1,2 @@
import{options as n}from"./preact.js";var t,r,u,i,o=0,f=[],c=[],e=n,a=e.__b,v=e.__r,l=e.diffed,m=e.__c,s=e.unmount,d=e.__;function h(n,t){e.__h&&e.__h(r,n,o||t),o=0;var u=r.__H||(r.__H={__:[],__h:[]});return n>=u.__.length&&u.__.push({__V:c}),u.__[n]}function p(n){return o=1,y(D,n)}function y(n,u,i){var o=h(t++,2);if(o.t=n,!o.__c&&(o.__=[i?i(u):D(void 0,u),function(n){var t=o.__N?o.__N[0]:o.__[0],r=o.t(t,n);t!==r&&(o.__N=[r,o.__[1]],o.__c.setState({}))}],o.__c=r,!r.u)){var f=function(n,t,r){if(!o.__c.__H)return!0;var u=o.__c.__H.__.filter(function(n){return!!n.__c});if(u.every(function(n){return!n.__N}))return!c||c.call(this,n,t,r);var i=!1;return u.forEach(function(n){if(n.__N){var t=n.__[0];n.__=n.__N,n.__N=void 0,t!==n.__[0]&&(i=!0)}}),!(!i&&o.__c.props===n)&&(!c||c.call(this,n,t,r))};r.u=!0;var c=r.shouldComponentUpdate,e=r.componentWillUpdate;r.componentWillUpdate=function(n,t,r){if(this.__e){var u=c;c=void 0,f(n,t,r),c=u}e&&e.call(this,n,t,r)},r.shouldComponentUpdate=f}return o.__N||o.__}function _(n,u){var i=h(t++,3);!e.__s&&C(i.__H,u)&&(i.__=n,i.i=u,r.__H.__h.push(i))}function A(n,u){var i=h(t++,4);!e.__s&&C(i.__H,u)&&(i.__=n,i.i=u,r.__h.push(i))}function F(n){return o=5,q(function(){return{current:n}},[])}function T(n,t,r){o=6,A(function(){return"function"==typeof n?(n(t()),function(){return n(null)}):n?(n.current=t(),function(){return n.current=null}):void 0},null==r?r:r.concat(n))}function q(n,r){var u=h(t++,7);return C(u.__H,r)?(u.__V=n(),u.i=r,u.__h=n,u.__V):u.__}function x(n,t){return o=8,q(function(){return n},t)}function P(n){var u=r.context[n.__c],i=h(t++,9);return i.c=n,u?(null==i.__&&(i.__=!0,u.sub(r)),u.props.value):n.__}function V(n,t){e.useDebugValue&&e.useDebugValue(t?t(n):n)}function b(n){var u=h(t++,10),i=p();return u.__=n,r.componentDidCatch||(r.componentDidCatch=function(n,t){u.__&&u.__(n,t),i[1](n)}),[i[0],function(){i[1](void 0)}]}function g(){var n=h(t++,11);if(!n.__){for(var u=r.__v;null!==u&&!u.__m&&null!==u.__;)u=u.__;var i=u.__m||(u.__m=[0,0]);n.__="P"+i[0]+"-"+i[1]++}return n.__}function j(){for(var n;n=f.shift();)if(n.__P&&n.__H)try{n.__H.__h.forEach(z),n.__H.__h.forEach(B),n.__H.__h=[]}catch(t){n.__H.__h=[],e.__e(t,n.__v)}}e.__b=function(n){r=null,a&&a(n)},e.__=function(n,t){n&&t.__k&&t.__k.__m&&(n.__m=t.__k.__m),d&&d(n,t)},e.__r=function(n){v&&v(n),t=0;var i=(r=n.__c).__H;i&&(u===r?(i.__h=[],r.__h=[],i.__.forEach(function(n){n.__N&&(n.__=n.__N),n.__V=c,n.__N=n.i=void 0})):(i.__h.forEach(z),i.__h.forEach(B),i.__h=[],t=0)),u=r},e.diffed=function(n){l&&l(n);var t=n.__c;t&&t.__H&&(t.__H.__h.length&&(1!==f.push(t)&&i===e.requestAnimationFrame||((i=e.requestAnimationFrame)||w)(j)),t.__H.__.forEach(function(n){n.i&&(n.__H=n.i),n.__V!==c&&(n.__=n.__V),n.i=void 0,n.__V=c})),u=r=null},e.__c=function(n,t){t.some(function(n){try{n.__h.forEach(z),n.__h=n.__h.filter(function(n){return!n.__||B(n)})}catch(r){t.some(function(n){n.__h&&(n.__h=[])}),t=[],e.__e(r,n.__v)}}),m&&m(n,t)},e.unmount=function(n){s&&s(n);var t,r=n.__c;r&&r.__H&&(r.__H.__.forEach(function(n){try{z(n)}catch(n){t=n}}),r.__H=void 0,t&&e.__e(t,r.__v))};var k="function"==typeof requestAnimationFrame;function w(n){var t,r=function(){clearTimeout(u),k&&cancelAnimationFrame(t),setTimeout(n)},u=setTimeout(r,100);k&&(t=requestAnimationFrame(r))}function z(n){var t=r,u=n.__c;"function"==typeof u&&(n.__c=void 0,u()),r=t}function B(n){var t=r;n.__c=n.__(),r=t}function C(n,t){return!n||n.length!==t.length||t.some(function(t,r){return t!==n[r]})}function D(n,t){return"function"==typeof t?t(n):t}export{x as useCallback,P as useContext,V as useDebugValue,_ as useEffect,b as useErrorBoundary,g as useId,T as useImperativeHandle,A as useLayoutEffect,q as useMemo,y as useReducer,F as useRef,p as useState};
//# sourceMappingURL=hooks.module.js.map
+2
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,10 +6,10 @@
<meta name="description" content="SunScope - UTCI weather forecast showing real thermal comfort, not just air temperature." />
<title>SunScope · The True Temperature</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☀️</text></svg>" />
<link rel="stylesheet" crossorigin href="./assets/index-CD_5RYSA.css">
<script type="module" crossorigin src="./assets/index-cxJ3Sbx3.js"></script>
<link rel="stylesheet" href="./assets/sunscope.css?v=14d-local9">
</head>
<body>
<div id="root"></div>
<script type="module" src="./assets/sunscope.js?v=14d-local9"></script>
</body>
</html>
-874
View File
@@ -1,874 +0,0 @@
import { useState, useEffect, useRef } from 'preact/hooks';
import './styles.css';
// ═══════════════════════════════════════════════════════════════════
// PHYSICAL CONSTANTS
// ═══════════════════════════════════════════════════════════════════
const SIGMA = 5.670374419e-8;
const EPSILON_P = 0.97;
const A_K = 0.7;
const ALBEDO_GRASS = 0.23;
// ═══════════════════════════════════════════════════════════════════
// VAPOR PRESSURE (Magnus formula → hPa)
// ═══════════════════════════════════════════════════════════════════
function vaporPressureHpa(Ta, RH) {
const es = 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
return es * (RH / 100);
}
// ═══════════════════════════════════════════════════════════════════
// SOLAR POSITION (NOAA simplified; returns elevation in degrees)
// ═══════════════════════════════════════════════════════════════════
function solarElevationDeg(lat, lon, dateUTC) {
const start = Date.UTC(dateUTC.getUTCFullYear(), 0, 0);
const diff = dateUTC - start;
const DOY = Math.floor(diff / 86400000);
const hourUTC =
dateUTC.getUTCHours() +
dateUTC.getUTCMinutes() / 60 +
dateUTC.getUTCSeconds() / 3600;
const gamma = ((2 * Math.PI) / 365) * (DOY - 1 + (hourUTC - 12) / 24);
const eqtime =
229.18 *
(0.000075 +
0.001868 * Math.cos(gamma) -
0.032077 * Math.sin(gamma) -
0.014615 * Math.cos(2 * gamma) -
0.040849 * Math.sin(2 * gamma));
const decl =
0.006918 -
0.399912 * Math.cos(gamma) +
0.070257 * Math.sin(gamma) -
0.006758 * Math.cos(2 * gamma) +
0.000907 * Math.sin(2 * gamma) -
0.002697 * Math.cos(3 * gamma) +
0.00148 * Math.sin(3 * gamma);
const timeOffset = eqtime + 4 * lon;
const tst = hourUTC * 60 + timeOffset;
const ha = (((tst / 4) - 180) * Math.PI) / 180;
const latRad = (lat * Math.PI) / 180;
const cosZenith =
Math.sin(latRad) * Math.sin(decl) +
Math.cos(latRad) * Math.cos(decl) * Math.cos(ha);
const zenith = Math.acos(Math.max(-1, Math.min(1, cosZenith)));
return (Math.PI / 2 - zenith) * (180 / Math.PI);
}
// ═══════════════════════════════════════════════════════════════════
// MEAN RADIANT TEMPERATURE
// ═══════════════════════════════════════════════════════════════════
function calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) {
const TaK = Ta + 273.15;
let fp = 0;
if (solElev > 0) {
const h = solElev;
fp = 0.308 * Math.cos((Math.PI / 180) * h * (0.998 - (h * h) / 50000));
}
let DNI = 0;
if (solElev > 1) {
DNI = dirRad / Math.sin((solElev * Math.PI) / 180);
DNI = Math.min(DNI, 1100);
}
const Sshort = A_K * (fp * DNI + 0.5 * diffRad + 0.5 * ALBEDO_GRASS * globalRad);
const Slong = EPSILON_P * SIGMA * Math.pow(TaK, 4);
const TmrtK = Math.pow((Sshort + Slong) / (EPSILON_P * SIGMA), 0.25);
return TmrtK - 273.15;
}
// ═══════════════════════════════════════════════════════════════════
// UTCI POLYNOMIAL APPROXIMATION (Bröde et al. 2012, 210 terms)
// ═══════════════════════════════════════════════════════════════════
function utciApprox(Ta, Tmrt, va10, ehPa) {
const va = Math.max(0.5, Math.min(17, va10));
const D_Tmrt = Tmrt - Ta;
const Pa = ehPa / 10;
const T = Ta, V = va, D = D_Tmrt, P = Pa;
const T2=T*T, T3=T2*T, T4=T3*T, T5=T4*T, T6=T5*T;
const V2=V*V, V3=V2*V, V4=V3*V, V5=V4*V, V6=V5*V;
const D2=D*D, D3=D2*D, D4=D3*D, D5=D4*D, D6=D5*D;
const P2=P*P, P3=P2*P, P4=P3*P, P5=P4*P, P6=P5*P;
return T +
6.07562052e-1 +
-2.27712343e-2 * T +
8.06470249e-4 * T2 +
-1.54271372e-4 * T3 +
-3.24651735e-6 * T4 +
7.32602852e-8 * T5 +
1.35959073e-9 * T6 +
-2.25836520e0 * V +
8.80326035e-2 * T*V +
2.16844454e-3 * T2*V +
-1.53347087e-5 * T3*V +
-5.72983704e-7 * T4*V +
-2.55090145e-9 * T5*V +
-7.51269505e-1 * V2 +
-4.08350271e-3 * T*V2 +
-5.21670675e-5 * T2*V2 +
1.94544667e-6 * T3*V2 +
1.14099531e-8 * T4*V2 +
1.58137256e-1 * V3 +
-6.57263143e-5 * T*V3 +
2.22697524e-7 * T2*V3 +
-4.16117031e-8 * T3*V3 +
-1.27762753e-2 * V4 +
9.66891875e-6 * T*V4 +
2.52785852e-9 * T2*V4 +
4.56306672e-4 * V5 +
-1.74202546e-7 * T*V5 +
-5.91491269e-6 * V6 +
3.98374029e-1 * D +
1.83945314e-4 * T*D +
-1.73754510e-4 * T2*D +
-7.60781159e-7 * T3*D +
3.77830287e-8 * T4*D +
5.43079673e-10* T5*D +
-2.00518269e-2 * V*D +
8.92859837e-4 * T*V*D +
3.45433048e-6 * T2*V*D +
-3.77925774e-7 * T3*V*D +
-1.69699377e-9 * T4*V*D +
1.69992415e-4 * V2*D +
-4.99204314e-5 * T*V2*D +
2.47417178e-7 * T2*V2*D +
1.07596466e-8 * T3*V2*D +
8.49242932e-5 * V3*D +
1.35191328e-6 * T*V3*D +
-6.21531254e-9 * T2*V3*D +
-4.99410301e-6 * V4*D +
-1.89489258e-8 * T*V4*D +
8.15300114e-8 * V5*D +
7.55043090e-4 * D2 +
-5.65095215e-5 * T*D2 +
-4.52166564e-7 * T2*D2 +
2.46688878e-8 * T3*D2 +
2.42674348e-10* T4*D2 +
1.54547250e-4 * V*D2 +
5.24110970e-6 * T*V*D2 +
-8.75874982e-8 * T2*V*D2 +
-1.50743064e-9 * T3*V*D2 +
-1.56236307e-5 * V2*D2 +
-1.33895614e-7 * T*V2*D2 +
2.49709824e-9 * T2*V2*D2 +
6.51711721e-7 * V3*D2 +
1.94960053e-9 * T*V3*D2 +
-1.00361113e-8 * V4*D2 +
-1.21206673e-5 * D3 +
-2.18203660e-7 * T*D3 +
7.51269482e-9 * T2*D3 +
9.79063848e-11* T3*D3 +
1.25006734e-6 * V*D3 +
-1.81584736e-9 * T*V*D3 +
-3.52197671e-10* T2*V*D3 +
-3.36514630e-8 * V2*D3 +
1.35908359e-10* T*V2*D3 +
4.17032620e-10* V3*D3 +
-1.30369025e-9 * D4 +
4.13908461e-10* T*D4 +
9.22652254e-12* T2*D4 +
-5.08220384e-9 * V*D4 +
-2.24730961e-11* T*V*D4 +
1.17139133e-10* V2*D4 +
6.62154879e-10* D5 +
4.03863260e-13* T*D5 +
1.95087203e-12* V*D5 +
-4.73602469e-12* D6 +
5.12733497e0 * P +
-3.12788561e-1 * T*P +
-1.96701861e-2 * T2*P +
9.99690870e-4 * T3*P +
9.51738512e-6 * T4*P +
-4.66426341e-7 * T5*P +
5.48050612e-1 * V*P +
-3.30552823e-3 * T*V*P +
-1.64119440e-3 * T2*V*P +
-5.16670694e-6 * T3*V*P +
9.52692432e-7 * T4*V*P +
-4.29223622e-2 * V2*P +
5.00845667e-3 * T*V2*P +
1.00601257e-6 * T2*V2*P +
-1.81748644e-6 * T3*V2*P +
-1.25813502e-3 * V3*P +
-1.79330391e-4 * T*V3*P +
2.34994441e-6 * T2*V3*P +
1.29735808e-4 * V4*P +
1.29064870e-6 * T*V4*P +
-2.28558686e-6 * V5*P +
-3.69476348e-2 * D*P +
1.62325322e-3 * T*D*P +
-3.14279680e-5 * T2*D*P +
2.59835559e-6 * T3*D*P +
-4.77136523e-8 * T4*D*P +
8.64203390e-3 * V*D*P +
-6.87405181e-4 * T*V*D*P +
-9.13863872e-6 * T2*V*D*P +
5.15916806e-7 * T3*V*D*P +
-3.59217476e-5 * V2*D*P +
3.28696511e-5 * T*V2*D*P +
-7.10542454e-7 * T2*V2*D*P +
-1.24382300e-5 * V3*D*P +
-7.38584400e-9 * T*V3*D*P +
2.20609296e-7 * V4*D*P +
-7.32469180e-4 * D2*P +
-1.87381964e-5 * T*D2*P +
4.80925239e-6 * T2*D2*P +
-8.75492040e-8 * T3*D2*P +
2.77862930e-5 * V*D2*P +
-5.06004592e-6 * T*V*D2*P +
1.14325367e-7 * T2*V*D2*P +
2.53016723e-6 * V2*D2*P +
-1.72857035e-8 * T*V2*D2*P +
-3.95079398e-8 * V3*D2*P +
-3.59413173e-7 * D3*P +
7.04388046e-7 * T*D3*P +
-1.89309167e-8 * T2*D3*P +
-4.79768731e-7 * V*D3*P +
7.96079978e-9 * T*V*D3*P +
1.62897058e-9 * V2*D3*P +
3.94367674e-8 * D4*P +
-1.18566247e-9 * T*D4*P +
3.34678041e-10* V*D4*P +
-1.15606447e-10* D5*P +
-2.80626406e0 * P2 +
5.48712484e-1 * T*P2 +
-3.99428410e-3 * T2*P2 +
-9.54009191e-4 * T3*P2 +
1.93090978e-5 * T4*P2 +
-3.08806365e-1 * V*P2 +
1.16952364e-2 * T*V*P2 +
4.95271903e-4 * T2*V*P2 +
-1.90710882e-5 * T3*V*P2 +
2.10787756e-3 * V2*P2 +
-6.98445738e-4 * T*V2*P2 +
2.30109073e-5 * T2*V2*P2 +
4.17856590e-4 * V3*P2 +
-1.27043871e-5 * T*V3*P2 +
-3.04620472e-6 * V4*P2 +
5.14507424e-2 * D*P2 +
-4.32510997e-3 * T*D*P2 +
8.99281156e-5 * T2*D*P2 +
-7.14663943e-7 * T3*D*P2 +
-2.66016305e-4 * V*D*P2 +
2.63789586e-4 * T*V*D*P2 +
-7.01199003e-6 * T2*V*D*P2 +
-1.06823306e-4 * V2*D*P2 +
3.61341136e-6 * T*V2*D*P2 +
2.29748967e-7 * V3*D*P2 +
3.04788893e-4 * D2*P2 +
-6.42070836e-5 * T*D2*P2 +
1.16257971e-6 * T2*D2*P2 +
7.68023384e-6 * V*D2*P2 +
-5.47446896e-7 * T*V*D2*P2 +
-3.59937910e-8 * V2*D2*P2 +
-4.36497725e-6 * D3*P2 +
1.68737969e-7 * T*D3*P2 +
2.67489271e-8 * V*D3*P2 +
3.23926897e-9 * D4*P2 +
-3.53874123e-2 * P3 +
-2.21201190e-1 * T*P3 +
1.55126038e-2 * T2*P3 +
-2.63917279e-4 * T3*P3 +
4.53433455e-2 * V*P3 +
-4.32943862e-3 * T*V*P3 +
1.45389826e-4 * T2*V*P3 +
2.17508610e-4 * V2*P3 +
-6.66724702e-5 * T*V2*P3 +
3.33217140e-5 * V3*P3 +
-2.26921615e-3 * D*P3 +
3.80261982e-4 * T*D*P3 +
-5.45314314e-9 * T2*D*P3 +
-7.96355448e-4 * V*D*P3 +
2.53458034e-5 * T*V*D*P3 +
-6.31223658e-6 * V2*D*P3 +
3.02122035e-4 * D2*P3 +
-4.77403547e-6 * T*D2*P3 +
1.73825715e-6 * V*D2*P3 +
-4.09087898e-7 * D3*P3 +
6.14155345e-1 * P4 +
-6.16755931e-2 * T*P4 +
1.33374846e-3 * T2*P4 +
3.55375387e-3 * V*P4 +
-5.13027851e-4 * T*V*P4 +
1.02449757e-4 * V2*P4 +
-1.48526421e-3 * D*P4 +
-4.11469183e-5 * T*D*P4 +
-6.80434415e-6 * V*D*P4 +
-9.77675906e-6 * D2*P4 +
8.82773108e-2 * P5 +
-3.01859306e-3 * T*P5 +
1.04452989e-3 * V*P5 +
2.47090539e-4 * D*P5 +
1.48348065e-3 * P6;
}
// ═══════════════════════════════════════════════════════════════════
// UTCI STRESS BANDS
// ═══════════════════════════════════════════════════════════════════
function utciCategory(u) {
if (u < -40) return { label: 'Extreme cold', bg: '#1a1438', fg: '#fff' };
if (u < -27) return { label: 'Very strong cold', bg: '#23408f', fg: '#fff' };
if (u < -13) return { label: 'Arctic', bg: '#3f73c4', fg: '#fff' };
if (u < 0) return { label: 'Freezing', bg: '#7eb0e0', fg: '#1a1612' };
if (u < 9) return { label: 'Cold', bg: '#bcd9ec', fg: '#1a1612' };
if (u < 18) return { label: 'Chilled', bg: '#c8dcc0', fg: '#1a1612' };
if (u < 26) return { label: 'Comfortable', bg: '#6ab05a', fg: '#fff' };
if (u < 32) return { label: 'Moderate heat', bg: '#e8c547', fg: '#1a1612' };
if (u < 38) return { label: 'Strong heat', bg: '#dc8a3a', fg: '#1a1612' };
if (u < 46) return { label: 'Very strong heat', bg: '#c44a3a', fg: '#fff' };
return { label: 'Extreme heat', bg: '#7a1a1a', fg: '#fff' };
}
// ═══════════════════════════════════════════════════════════════════
// SCOPE RETICLE — shows current UTCI like a precision instrument
// (uses only basic SVG primitives for maximum compatibility)
// ═══════════════════════════════════════════════════════════════════
function ScopeReticle({ value, cat, loading }) {
const cx = 100, cy = 100, R = 86;
// 36 tick marks around the ring
const ticks = Array.from({ length: 36 }, (_, i) => {
const deg = i * 10 - 90;
const rad = (deg * Math.PI) / 180;
const major = i % 9 === 0;
const medium = i % 3 === 0;
const r2 = major ? R - 14 : medium ? R - 8 : R - 4;
return {
x1: cx + R * Math.cos(rad), y1: cy + R * Math.sin(rad),
x2: cx + r2 * Math.cos(rad), y2: cy + r2 * Math.sin(rad),
major, medium,
};
});
// Stress band arc segments: UTCI -40..50 → 135°..405° (270° sweep)
const stressBands = [
{ min: -40, max: -27, color: '#23408f' },
{ min: -27, max: -13, color: '#3f73c4' },
{ min: -13, max: 0, color: '#7eb0e0' },
{ min: 0, max: 9, color: '#bcd9ec' },
{ min: 9, max: 18, color: '#c8dcc0' },
{ min: 18, max: 26, color: '#6ab05a' },
{ min: 26, max: 32, color: '#e8c547' },
{ min: 32, max: 38, color: '#dc8a3a' },
{ min: 38, max: 46, color: '#c44a3a' },
{ min: 46, max: 50, color: '#7a1a1a' },
];
function fracToXY(frac, r) {
const deg = 135 + frac * 270;
const rad = (deg * Math.PI) / 180;
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
}
function bandArcPath(band) {
const f1 = Math.min(1, Math.max(0, (band.min + 10) / 60));
const f2 = Math.min(1, Math.max(0, (band.max + 10) / 60));
const arcR = R - 18;
const [x1, y1] = fracToXY(f1, arcR);
const [x2, y2] = fracToXY(f2, arcR);
const large = (f2 - f1) * 270 > 180 ? 1 : 0;
return `M ${x1} ${y1} A ${arcR} ${arcR} 0 ${large} 1 ${x2} ${y2}`;
}
// Needle position
let needleX = cx, needleY = cy + 54;
if (value != null) {
const frac = Math.min(1, Math.max(0, (value + 10) / 60));
const deg = 135 + frac * 270;
const rad = (deg * Math.PI) / 180;
needleX = cx + 54 * Math.cos(rad);
needleY = cy + 54 * Math.sin(rad);
}
const glowColor = cat ? cat.bg : '#c8922a';
return (
<svg viewBox="0 0 200 200" className="scope-ring" aria-label="Current UTCI scope readout">
<defs>
<radialGradient id="scope-bg-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color={glowColor} stop-opacity="0.12" />
<stop offset="100%" stop-color={glowColor} stop-opacity="0" />
</radialGradient>
</defs>
{/* Ambient glow */}
<circle cx={cx} cy={cy} r="96" fill="url(#scope-bg-glow)" />
{/* Stress band arcs */}
{stressBands.map((b, i) => (
<path key={i} d={bandArcPath(b)} fill="none"
stroke={b.color} stroke-width="4" opacity="0.4" stroke-linecap="butt" />
))}
{/* Outer ring */}
<circle cx={cx} cy={cy} r={R} fill="none" stroke="#c9b08a" stroke-width="1.5" />
{/* Tick marks */}
{ticks.map((t, i) => (
<line key={i} x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2}
stroke={t.major ? '#c8922a' : t.medium ? '#c9b08a' : '#e0d0b0'}
stroke-width={t.major ? 1.5 : 0.75}
/>
))}
{/* Crosshair lines */}
<line x1={cx - R + 3} y1={cy} x2={cx - 32} y2={cy} stroke="#d4b896" stroke-width="0.75" />
<line x1={cx + 32} y1={cy} x2={cx + R - 3} y2={cy} stroke="#d4b896" stroke-width="0.75" />
<line x1={cx} y1={cy - R + 3} x2={cx} y2={cy - 32} stroke="#d4b896" stroke-width="0.75" />
<line x1={cx} y1={cy + 32} x2={cx} y2={cy + R - 3} stroke="#d4b896" stroke-width="0.75" />
{/* Inner rings */}
<circle cx={cx} cy={cy} r="58" fill="none" stroke="#e8d8c0" stroke-width="0.75" />
<circle cx={cx} cy={cy} r="32" fill="none" stroke="#e8d8c0" stroke-width="0.5" />
{/* Corner bracket markers */}
{[[-1,-1],[1,-1],[-1,1],[1,1]].map(([sx, sy], i) => (
<g key={i}>
<line x1={cx + sx*72} y1={cy + sy*72} x2={cx + sx*60} y2={cy + sy*72}
stroke="#c9b08a" stroke-width="1" />
<line x1={cx + sx*72} y1={cy + sy*72} x2={cx + sx*72} y2={cy + sy*60}
stroke="#c9b08a" stroke-width="1" />
</g>
))}
{/* Needle */}
{value != null && (
<>
<line x1={cx} y1={cy} x2={needleX} y2={needleY}
stroke="#c8922a" stroke-width="3" stroke-linecap="round" opacity="0.18" />
<line x1={cx} y1={cy} x2={needleX} y2={needleY}
stroke="#c8922a" stroke-width="1.5" stroke-linecap="round" opacity="0.95" />
</>
)}
{/* Centre pivot */}
<circle cx={cx} cy={cy} r="5.5" fill="#f5edd6" stroke="#c8922a" stroke-width="1.5" />
<circle cx={cx} cy={cy} r="2.5" fill="#c8922a" />
{/* Readout text */}
{loading ? (
<text x={cx} y={cy + 5} text-anchor="middle"
fill="#b09870" font-size="11" font-family="monospace">· · ·</text>
) : value != null ? (
<>
<text x={cx} y={cy - 8} text-anchor="middle"
fill="#1e1208" font-size="26"
font-family="Fraunces, serif" font-weight="700">
{value.toFixed(1)}°
</text>
<text x={cx} y={cy + 7} text-anchor="middle"
fill="#9a7d5a" font-size="6.5"
font-family="JetBrains Mono, monospace" letter-spacing="2">
UTCI NOW
</text>
<text x={cx} y={cy + 19} text-anchor="middle"
fill={glowColor} font-size="7"
font-family="JetBrains Mono, monospace" letter-spacing="0.8">
{cat.label.toUpperCase()}
</text>
</>
) : (
<text x={cx} y={cy + 5} text-anchor="middle"
fill="#b09870" font-size="8"
font-family="JetBrains Mono, monospace" letter-spacing="1.2">
AWAITING
</text>
)}
</svg>
);
}
// ═══════════════════════════════════════════════════════════════════
// MAIN COMPONENT
// ═══════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════
// PRECIPITATION PENALTY (applied on top of UTCI)
// Rain → evaporative + wet-clothing cooling, amplified by wind
// Snow → heavier conductive penalty on top of any rain
// ═══════════════════════════════════════════════════════════════════
function precipPenalty(precipMm, snowCmH, windMs) {
let penalty = 0;
if (precipMm > 0) {
// Scales: drizzle 0.1mm→-1.2°, moderate 2mm→-3.8°, heavy 8mm→-6.5°
const base = Math.min(7, 1.4 * Math.pow(precipMm, 0.55) + precipMm * 0.28);
// Wind amplifies wet chill (up to +35% at gale force)
const windMult = 1 + Math.min(0.35, windMs * 0.025);
penalty += base * windMult;
}
if (snowCmH > 0) {
// Snow adds extra penalty on top (wet snow especially brutal)
penalty += Math.min(6, 2.2 + snowCmH * 1.6);
}
return -Math.round(penalty * 10) / 10;
}
export default function UTCIForecast() {
const [location, setLocation] = useState({
name: 'Pangbourne, Berkshire',
lat: 51.4839,
lon: -1.0725,
country: 'GB',
});
const [forecast, setForecast] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [searching, setSearching] = useState(false);
const [selectedDay, setSelectedDay] = useState(0);
const [visibleCols, setVisibleCols] = useState({
hour: true, air: true, rh: true, wind: true,
cloud: false, sun: false, direct: false, diffuse: false,
tmrt: false, delta: false, utci: false, utciP: true,
precip: true,
});
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
const searchTimeout = useRef(null);
// Geocoding search
useEffect(() => {
if (searchQuery.length < 2) { setSearchResults([]); return; }
if (searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(async () => {
setSearching(true);
try {
const r = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(searchQuery)}&count=6&language=en&format=json`
);
const j = await r.json();
setSearchResults(j.results || []);
} catch { setSearchResults([]); }
finally { setSearching(false); }
}, 300);
}, [searchQuery]);
// Forecast fetch
useEffect(() => {
async function load() {
setLoading(true); setError(null);
try {
const url =
`https://api.open-meteo.com/v1/forecast` +
`?latitude=${location.lat}&longitude=${location.lon}` +
`&hourly=temperature_2m,relative_humidity_2m,wind_speed_10m,` +
`direct_radiation,diffuse_radiation,shortwave_radiation,cloud_cover,` +
`precipitation,snowfall` +
`&wind_speed_unit=ms&timezone=auto&forecast_days=3`;
const r = await fetch(url);
if (!r.ok) throw new Error(`Open-Meteo HTTP ${r.status}`);
setForecast(await r.json());
} catch (e) { setError(e.message); }
finally { setLoading(false); }
}
load();
}, [location]);
// Compute hourly rows
const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => {
const Ta = forecast.hourly.temperature_2m[i];
const RH = forecast.hourly.relative_humidity_2m[i];
const va = forecast.hourly.wind_speed_10m[i];
const dir = forecast.hourly.direct_radiation[i] || 0;
const dif = forecast.hourly.diffuse_radiation[i] || 0;
const glob = forecast.hourly.shortwave_radiation[i] || 0;
const cc = forecast.hourly.cloud_cover[i];
const precip = forecast.hourly.precipitation[i] || 0;
const snow = forecast.hourly.snowfall[i] || 0;
const dt = new Date(iso);
const elev = solarElevationDeg(location.lat, location.lon, dt);
const eh = vaporPressureHpa(Ta, RH);
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
const utci = utciApprox(Ta, Tmrt, va, eh);
const utciAdj = utci + precipPenalty(precip, snow, va);
return { iso, dt, Ta, RH, va, dir, dif, glob, cc, precip, snow, elev, Tmrt, utci, utciAdj, eh };
}) : [];
// Group by day
const days = [];
hourlyRows.forEach(row => {
const key = row.iso.slice(0, 10);
let day = days.find(d => d.key === key);
if (!day) { day = { key, date: new Date(row.iso), rows: [] }; days.push(day); }
day.rows.push(row);
});
const visible = days[selectedDay]?.rows || [];
// Current moment — for reticle + row highlight
const now = new Date();
const currentRow = hourlyRows.length > 0
? (hourlyRows.find(row =>
now.toDateString() === row.dt.toDateString() &&
now.getHours() === row.dt.getHours()
) ?? hourlyRows.reduce((best, row) =>
Math.abs(row.dt - now) < Math.abs(best.dt - now) ? row : best))
: null;
const currentCat = currentRow
? utciCategory(currentRow.utciAdj)
: { bg: '#4a4228', fg: '#ede4cc', label: 'No data' };
return (
<div className="utci-app">
<nav className="utci-topnav">
<a href="./index.html">Home</a>
<a href="./about.html">About</a>
</nav>
<div className="lens-bloom-a" />
<div className="lens-bloom-b" />
<div className="utci-shell">
<div className="utci-header">
<div>
<h1 className="utci-title"><span className="title-sun">SUN</span><span className="title-scope">Scope</span> <sub className="title-beta">beta</sub></h1>
<div className="utci-tagline">See the sun the way your body does.</div>
<div className="utci-subtitle"><a href="https://utci.org/" target="_blank" rel="noopener noreferrer" className="utci-subtitle-link">Universal Thermal Climate Index</a> · Bröde 2012 · Open-Meteo · SunScope soak-factor</div>
<div className="utci-current-loc">
{location.name}
<span className="utci-loc-coords">
{location.lat.toFixed(3)}°, {location.lon.toFixed(3)}°
</span>
</div>
</div>
<div>
<ScopeReticle
value={currentRow?.utciAdj ?? null}
cat={currentCat}
loading={loading}
/>
</div>
<div className="header-right">
<div className="utci-search-wrap">
<label className="utci-search-label">Change location</label>
<input
className="utci-search"
type="text"
placeholder="Search any town or city…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchResults.length > 0 && (
<div className="utci-results">
{searchResults.map((r) => (
<div
key={`${r.id}-${r.latitude}`}
className="utci-result"
onClick={() => {
setLocation({
name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`,
lat: r.latitude,
lon: r.longitude,
country: r.country_code,
});
setSearchQuery('');
setSearchResults([]);
setSelectedDay(0);
}}
>
<div>{r.name}{r.admin1 ? `, ${r.admin1}` : ''}</div>
<div className="utci-result-meta">
{r.country} · {r.latitude.toFixed(2)}°, {r.longitude.toFixed(2)}°
</div>
</div>
))}
</div>
)}
{searching && <div className="utci-searching">Searching</div>}
</div>
</div>
</div>
{error && (
<div className="utci-status" style={{ borderColor: '#3a1010', color: '#c44a3a' }}>
{error}
</div>
)}
{loading && !error && (
<div className="utci-status">Acquiring forecast data</div>
)}
{forecast && days.length > 0 && (
<>
<div className="utci-day-tabs">
{days.map((d, i) => (
<button
key={d.key}
className={`utci-day-tab ${i === selectedDay ? 'active' : ''}`}
onClick={() => setSelectedDay(i)}
>
{i === 0 ? 'Today' : i === 1 ? 'Tomorrow' : 'Day after'}
<span className="utci-day-date">
{d.date.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })}
</span>
</button>
))}
</div>
<div className="col-toggles">
<span className="col-toggles-label">Columns:</span>
{[
{ key: 'hour', label: 'Hour' },
{ key: 'air', label: 'Air' },
{ key: 'rh', label: 'RH' },
{ key: 'wind', label: 'Wind' },
{ key: 'cloud', label: 'Cloud' },
{ key: 'sun', label: 'Sun' },
{ key: 'direct', label: 'Direct' },
{ key: 'diffuse', label: 'Diffuse' },
{ key: 'tmrt', label: 'Tmrt' },
{ key: 'delta', label: 'Δ' },
{ key: 'utci', label: 'UTCI' },
{ key: 'precip', label: 'Precip' },
{ key: 'utciP', label: 'UTCI+P' },
].map(c => (
<button
key={c.key}
className={`col-toggle${visibleCols[c.key] ? ' on' : ''}`}
onClick={() => toggleCol(c.key)}
>
{c.label}
</button>
))}
</div>
<div className="utci-table-wrap">
<table className="utci-table">
<thead>
<tr>
{visibleCols.hour && <th>Hour</th>}
{visibleCols.air && <th>Air <span className="col-unit">°C</span></th>}
{visibleCols.rh && <th>RH <span className="col-unit">%</span></th>}
{visibleCols.wind && <th>Wind <span className="col-unit">m/s</span></th>}
{visibleCols.cloud && <th>Cloud <span className="col-unit">%</span></th>}
{visibleCols.sun && <th>Sun <span className="col-unit">elev°</span></th>}
{visibleCols.direct && <th>Direct <span className="col-unit">W/</span></th>}
{visibleCols.diffuse && <th>Diffuse <span className="col-unit">W/</span></th>}
{visibleCols.tmrt && <th>Tmrt <span className="col-unit">°C</span></th>}
{visibleCols.delta && <th>Δ <span className="col-unit">UTCIAir</span></th>}
{visibleCols.utci && <th>UTCI <span className="col-unit">°C felt</span></th>}
{visibleCols.precip && <th>Precip <span className="col-unit">mm/h</span></th>}
{visibleCols.utciP && <th>UTCI+P <span className="col-unit">°C adj.</span></th>}
</tr>
</thead>
<tbody>
{visible.map((r) => {
const cat = utciCategory(r.utci);
const isNight = r.elev < 0;
const isNow =
now.toDateString() === r.dt.toDateString() &&
now.getHours() === r.dt.getHours();
const delta = r.utci - r.Ta;
return (
<tr key={r.iso}
className={[isNight ? 'is-night' : '', isNow ? 'is-now' : ''].join(' ')}>
{visibleCols.hour && <td className="utci-time">
{isNow && <span className="now-pip" />}
{r.dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
</td>}
{visibleCols.air && <td>{r.Ta.toFixed(1)}</td>}
{visibleCols.rh && <td>{Math.round(r.RH)}</td>}
{visibleCols.wind && <td>{r.va.toFixed(1)}</td>}
{visibleCols.cloud && <td>{Math.round(r.cc)}</td>}
{visibleCols.sun && <td>{r.elev > 0 ? r.elev.toFixed(1) : '—'}</td>}
{visibleCols.direct && <td>{Math.round(r.dir)}</td>}
{visibleCols.diffuse && <td>{Math.round(r.dif)}</td>}
{visibleCols.tmrt && <td>{r.Tmrt.toFixed(1)}</td>}
{visibleCols.delta && <td style={{
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#9a7d5a',
fontWeight: 600,
}}>
{delta > 0 ? '+' : ''}{delta.toFixed(1)}
</td>}
{visibleCols.utci && <td>
<span className="utci-cell" style={{ background: cat.bg, color: cat.fg }}>
{r.utci.toFixed(1)}
</span>
</td>}
{visibleCols.precip && <td style={{ color: r.snow > 0 ? '#6090c8' : r.precip > 0 ? '#5090b0' : '#c0a880' }}>
{r.snow > 0 ? '❅ ' + r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'}
</td>}
{visibleCols.utciP && (() => {
const adjCat = utciCategory(r.utciAdj);
return <td style={{ background: 'rgba(180,215,250,0.10)' }}>
<span className="utci-cell" style={{ background: adjCat.bg, color: adjCat.fg }}>
{r.utciAdj.toFixed(1)}
</span>
</td>;
})()}
</tr>
);
})}
</tbody>
</table>
</div>
<div className="utci-legend">
<span className="utci-legend-label">Thermal stress bands</span>
<div className="utci-legend-row">
{[
{ l: '-27 to -13 Arctic', bg: '#3f73c4', fg: '#fff' },
{ l: '-13 to 0 Freezing', bg: '#7eb0e0', fg: '#111' },
{ l: '0 to 9 Cold', bg: '#bcd9ec', fg: '#111' },
{ l: '9 to 18 Chilled', bg: '#c8dcc0', fg: '#111' },
{ l: '18 to 26 Comfortable', bg: '#6ab05a', fg: '#fff' },
{ l: '26 to 32 Mod heat', bg: '#e8c547', fg: '#111' },
{ l: '32 to 38 Strong heat', bg: '#dc8a3a', fg: '#111' },
{ l: '38 to 46 V. strong', bg: '#c44a3a', fg: '#fff' },
{ l: '> 46 Extreme heat', bg: '#7a1a1a', fg: '#fff' },
].map((b, i) => (
<span key={i} className="utci-legend-item" style={{ background: b.bg, color: b.fg }}>
{b.l}
</span>
))}
</div>
</div>
</>
)}
<div className="utci-about">
<h2 className="utci-about-heading">What is SunScope?</h2>
<p className="utci-about-text">
SunScope shows how the weather will actually <em>feel</em> on your body not just the air
temperature. It uses the <strong>Universal Thermal Climate Index (UTCI)</strong>, a
peer-reviewed biometeorological standard developed by Bröde et al. (2012) that combines
air temperature, humidity, wind speed, and solar radiation into a single <em>felt
temperature</em>. On a calm, sunny winter day UTCI can read several degrees warmer than
the thermometer; on a grey, blustery day it can read far colder. Forecast data is
sourced in real time from <strong>Open-Meteo</strong>, a free and open-source weather API,
and solar radiation is used to calculate Mean Radiant Temperature the heat your skin
absorbs from the sun making SunScope one of the most complete outdoor comfort forecasts
available for free.
</p>
<p className="utci-about-text">
The <strong>UTCI+P</strong> column adds the <strong>SunScope soak-factor</strong>: an
original precipitation penalty that accounts for the extra chill of rain and snow on
exposed skin and wet clothing. Light drizzle reduces the felt temperature by around
12 °C; heavy rain combined with wind can push it down by 78 °C. Snow carries an
additional penalty on top. The result is a honest, real-world comfort score for any
location worldwide simply search for your town or city and compare the three-day
hourly forecast.
</p>
</div>
<div className="utci-footer">
<em>Reading the table.</em> A large positive Δ means your body is absorbing
far more heat than the air temperature alone suggests typically due to direct solar radiation.
On clear sunny days this gap can exceed 10°C even at modest air temperatures.
</div>
</div>
</div>
);
}