From cce78feea705bea12a599e89d2f1aafb035c5580 Mon Sep 17 00:00:00 2001 From: fraxle Date: Mon, 11 May 2026 11:42:32 +0100 Subject: [PATCH] Beta2 Updates Now 14 Days Accuracy Indicators Sunrise/Sunset Moon Cycles Early Pro Limitations New Code Formatting --- assets/index-cxJ3Sbx3.js | 1 - assets/sunscope.css | 720 +++++++++++++++++++ assets/sunscope.js | 1234 +++++++++++++++++++++++++++++++++ assets/vendor/htm.js | 1 + assets/vendor/preact-hooks.js | 2 + assets/vendor/preact.js | 2 + index.html | 4 +- utci-forecast.jsx | 874 ----------------------- 8 files changed, 1961 insertions(+), 877 deletions(-) delete mode 100644 assets/index-cxJ3Sbx3.js create mode 100644 assets/sunscope.css create mode 100644 assets/sunscope.js create mode 100644 assets/vendor/htm.js create mode 100644 assets/vendor/preact-hooks.js create mode 100644 assets/vendor/preact.js delete mode 100644 utci-forecast.jsx diff --git a/assets/index-cxJ3Sbx3.js b/assets/index-cxJ3Sbx3.js deleted file mode 100644 index 06ac34d..0000000 --- a/assets/index-cxJ3Sbx3.js +++ /dev/null @@ -1 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))_(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const f of i.addedNodes)f.tagName==="LINK"&&f.rel==="modulepreload"&&_(f)}).observe(document,{childList:!0,subtree:!0});function n(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function _(s){if(s.ep)return;s.ep=!0;const i=n(s);fetch(s.href,i)}})();var Z,N,Re,L,ke,Le,je,te,V,E,Oe,le,re,ce,J={},Y=[],Xe=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,ee=Array.isArray;function U(e,t){for(var n in t)e[n]=t[n];return e}function se(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function Ze(e,t,n){var _,s,i,f={};for(i in t)i=="key"?_=t[i]:i=="ref"?s=t[i]:f[i]=t[i];if(arguments.length>2&&(f.children=arguments.length>3?Z.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(i in e.defaultProps)f[i]===void 0&&(f[i]=e.defaultProps[i]);return q(e,f,_,s,null)}function q(e,t,n,_,s){var i={type:e,props:t,key:n,ref:_,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:s??++Re,__i:-1,__u:0};return s==null&&N.vnode!=null&&N.vnode(i),i}function j(e){return e.children}function G(e,t){this.props=e,this.context=t}function O(e,t){if(t==null)return e.__?O(e.__,e.__i+1):null;for(var n;tt&&L.sort(je),e=L.shift(),t=L.length,et(e)}finally{L.length=Q.__r=0}}function Ee(e,t,n,_,s,i,f,r,l,c,d){var o,p,m,b,w,h,y,k=_&&_.__k||Y,C=t.length;for(l=tt(n,t,k,l,C),o=0;o0?f=e.__k[i]=q(f.type,f.props,f.key,f.ref?f.ref:null,f.__v):e.__k[i]=f,l=i+p,f.__=e,f.__b=e.__b+1,r=null,(c=f.__i=nt(f,n,l,o))!=-1&&(o--,(r=n[c])&&(r.__u|=2)),r==null||r.__v==null?(c==-1&&(s>d?p--:sl?p--:p++,f.__u|=4))):e.__k[i]=null;if(o)for(i=0;i(d?1:0)){for(s=n-1,i=n+1;s>=0||i=0?s--:i++])!=null&&!(2&c.__u)&&r==c.key&&l==c.type)return f}return-1}function Ne(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||Xe.test(t)?n:n+"px"}function z(e,t,n,_,s){var i,f;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof _=="string"&&(e.style.cssText=_=""),_)for(t in _)n&&t in n||Ne(e.style,t,"");if(n)for(t in n)_&&n[t]==_[t]||Ne(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")i=t!=(t=t.replace(Oe,"$1")),f=t.toLowerCase(),t=f in e||t=="onFocusOut"||t=="onFocusIn"?f.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+i]=n,n?_?n[E]=_[E]:(n[E]=le,e.addEventListener(t,i?ce:re,i)):e.removeEventListener(t,i?ce:re,i);else{if(s=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function Me(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t[V]==null)t[V]=le++;else if(t[V]0?e:ee(e)?e.map(Ve):U({},e)}function ot(e,t,n,_,s,i,f,r,l){var c,d,o,p,m,b,w,h=n.props||J,y=t.props,k=t.type;if(k=="svg"?s="http://www.w3.org/2000/svg":k=="math"?s="http://www.w3.org/1998/Math/MathML":s||(s="http://www.w3.org/1999/xhtml"),i!=null){for(c=0;c=n.__.length&&n.__.push({}),n.__[e]}function F(e){return X=1,at(Je,e)}function at(e,t,n){var _=ue(W++,2);if(_.t=e,!_.__c&&(_.__=[Je(void 0,t),function(r){var l=_.__N?_.__N[0]:_.__[0],c=_.t(l,r);l!==c&&(_.__N=[c,_.__[1]],_.__c.setState({}))}],_.__c=P,!P.__f)){var s=function(r,l,c){if(!_.__c.__H)return!0;var d=_.__c.__H.__.filter(function(p){return p.__c});if(d.every(function(p){return!p.__N}))return!i||i.call(this,r,l,c);var o=_.__c.props!==r;return d.some(function(p){if(p.__N){var m=p.__[0];p.__=p.__N,p.__N=void 0,m!==p.__[0]&&(o=!0)}}),i&&i.call(this,r,l,c)||o};P.__f=!0;var i=P.shouldComponentUpdate,f=P.componentWillUpdate;P.componentWillUpdate=function(r,l,c){if(this.__e){var d=i;i=void 0,s(r,l,c),i=d}f&&f.call(this,r,l,c)},P.shouldComponentUpdate=s}return _.__N||_.__}function Ae(e,t){var n=ue(W++,3);!$.__s&&Ke(n.__H,t)&&(n.__=e,n.u=t,P.__H.__h.push(n))}function lt(e){return X=5,st(function(){return{current:e}},[])}function st(e,t){var n=ue(W++,7);return Ke(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function _t(){for(var e;e=Ge.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(K),t.__h.some(ae),t.__h=[]}catch(n){t.__h=[],$.__e(n,e.__v)}}}$.__b=function(e){P=null,Te&&Te(e)},$.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),Ie&&Ie(e,t)},$.__r=function(e){Se&&Se(e),W=0;var t=(P=e.__c).__H;t&&(ne===P?(t.__h=[],P.__h=[],t.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.some(K),t.__h.some(ae),t.__h=[],W=0)),ne=P},$.diffed=function(e){Pe&&Pe(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(Ge.push(t)!==1&&Ce===$.requestAnimationFrame||((Ce=$.requestAnimationFrame)||dt)(_t)),t.__H.__.some(function(n){n.u&&(n.__H=n.u),n.u=void 0})),ne=P=null},$.__c=function(e,t){t.some(function(n){try{n.__h.some(K),n.__h=n.__h.filter(function(_){return!_.__||ae(_)})}catch(_){t.some(function(s){s.__h&&(s.__h=[])}),t=[],$.__e(_,n.__v)}}),$e&&$e(e,t)},$.unmount=function(e){De&&De(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.some(function(_){try{K(_)}catch(s){t=s}}),n.__H=void 0,t&&$.__e(t,n.__v))};var He=typeof requestAnimationFrame=="function";function dt(e){var t,n=function(){clearTimeout(_),He&&cancelAnimationFrame(t),setTimeout(e)},_=setTimeout(n,35);He&&(t=requestAnimationFrame(n))}function K(e){var t=P,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),P=t}function ae(e){var t=P;e.__c=e.__(),P=t}function Ke(e,t){return!e||e.length!==t.length||t.some(function(n,_){return n!==e[_]})}function Je(e,t){return typeof t=="function"?t(e):t}const Fe=5670374419e-17,Ue=.97,ut=.7,ht=.23;function ft(e,t){return 6.105*Math.exp(17.27*e/(237.7+e))*(t/100)}function mt(e,t,n){const _=Date.UTC(n.getUTCFullYear(),0,0),s=n-_,i=Math.floor(s/864e5),f=n.getUTCHours()+n.getUTCMinutes()/60+n.getUTCSeconds()/3600,r=2*Math.PI/365*(i-1+(f-12)/24),l=229.18*(75e-6+.001868*Math.cos(r)-.032077*Math.sin(r)-.014615*Math.cos(2*r)-.040849*Math.sin(2*r)),c=.006918-.399912*Math.cos(r)+.070257*Math.sin(r)-.006758*Math.cos(2*r)+907e-6*Math.sin(2*r)-.002697*Math.cos(3*r)+.00148*Math.sin(3*r),d=l+4*t,p=((f*60+d)/4-180)*Math.PI/180,m=e*Math.PI/180,b=Math.sin(m)*Math.sin(c)+Math.cos(m)*Math.cos(c)*Math.cos(p),w=Math.acos(Math.max(-1,Math.min(1,b)));return(Math.PI/2-w)*(180/Math.PI)}function pt(e,t,n,_,s){const i=e+273.15;let f=0;if(s>0){const o=s;f=.308*Math.cos(Math.PI/180*o*(.998-o*o/5e4))}let r=0;s>1&&(r=t/Math.sin(s*Math.PI/180),r=Math.min(r,1100));const l=ut*(f*r+.5*n+.5*ht*_),c=Ue*Fe*Math.pow(i,4);return Math.pow((l+c)/(Ue*Fe),.25)-273.15}function yt(e,t,n,_){const s=Math.max(.5,Math.min(17,n)),i=t-e,f=_/10,r=e,l=s,c=i,d=f,o=r*r,p=o*r,m=p*r,b=m*r,w=b*r,h=l*l,y=h*l,k=y*l,C=k*l,D=C*l,g=c*c,M=g*c,T=M*c,I=T*c,A=I*c,v=d*d,u=v*d,x=u*d,S=x*d,H=S*d;return r+.607562052+-.0227712343*r+.000806470249*o+-.000154271372*p+-324651735e-14*m+732602852e-16*b+135959073e-17*w+-2.2583652*l+.0880326035*r*l+.00216844454*o*l+-153347087e-13*p*l+-572983704e-15*m*l+-255090145e-17*b*l+-.751269505*h+-.00408350271*r*h+-521670675e-13*o*h+194544667e-14*p*h+114099531e-16*m*h+.158137256*y+-657263143e-13*r*y+222697524e-15*o*y+-416117031e-16*p*y+-.0127762753*k+966891875e-14*r*k+252785852e-17*o*k+.000456306672*C+-174202546e-15*r*C+-591491269e-14*D+.398374029*c+.000183945314*r*c+-.00017375451*o*c+-760781159e-15*p*c+377830287e-16*m*c+543079673e-18*b*c+-.0200518269*l*c+.000892859837*r*l*c+345433048e-14*o*l*c+-377925774e-15*p*l*c+-169699377e-17*m*l*c+.000169992415*h*c+-499204314e-13*r*h*c+247417178e-15*o*h*c+107596466e-16*p*h*c+849242932e-13*y*c+135191328e-14*r*y*c+-621531254e-17*o*y*c+-499410301e-14*k*c+-189489258e-16*r*k*c+815300114e-16*C*c+.00075504309*g+-565095215e-13*r*g+-452166564e-15*o*g+246688878e-16*p*g+242674348e-18*m*g+.00015454725*l*g+52411097e-13*r*l*g+-875874982e-16*o*l*g+-150743064e-17*p*l*g+-156236307e-13*h*g+-133895614e-15*r*h*g+249709824e-17*o*h*g+651711721e-15*y*g+194960053e-17*r*y*g+-100361113e-16*k*g+-121206673e-13*M+-21820366e-14*r*M+751269482e-17*o*M+979063848e-19*p*M+125006734e-14*l*M+-181584736e-17*r*l*M+-352197671e-18*o*l*M+-33651463e-15*h*M+135908359e-18*r*h*M+41703262e-17*y*M+-130369025e-17*T+413908461e-18*r*T+922652254e-20*o*T+-508220384e-17*l*T+-224730961e-19*r*l*T+117139133e-18*h*T+662154879e-18*I+40386326e-20*r*I+195087203e-20*l*I+-473602469e-20*A+5.12733497*d+-.312788561*r*d+-.0196701861*o*d+.00099969087*p*d+951738512e-14*m*d+-466426341e-15*b*d+.548050612*l*d+-.00330552823*r*l*d+-.0016411944*o*l*d+-516670694e-14*p*l*d+952692432e-15*m*l*d+-.0429223622*h*d+.00500845667*r*h*d+100601257e-14*o*h*d+-181748644e-14*p*h*d+-.00125813502*y*d+-.000179330391*r*y*d+234994441e-14*o*y*d+.000129735808*k*d+12906487e-13*r*k*d+-228558686e-14*C*d+-.0369476348*c*d+.00162325322*r*c*d+-31427968e-12*o*c*d+259835559e-14*p*c*d+-477136523e-16*m*c*d+.0086420339*l*c*d+-.000687405181*r*l*c*d+-913863872e-14*o*l*c*d+515916806e-15*p*l*c*d+-359217476e-13*h*c*d+328696511e-13*r*h*c*d+-710542454e-15*o*h*c*d+-1243823e-11*y*c*d+-7385844e-15*r*y*c*d+220609296e-15*k*c*d+-.00073246918*g*d+-187381964e-13*r*g*d+480925239e-14*o*g*d+-87549204e-15*p*g*d+27786293e-12*l*g*d+-506004592e-14*r*l*g*d+114325367e-15*o*l*g*d+253016723e-14*h*g*d+-172857035e-16*r*h*g*d+-395079398e-16*y*g*d+-359413173e-15*M*d+704388046e-15*r*M*d+-189309167e-16*o*M*d+-479768731e-15*l*M*d+796079978e-17*r*l*M*d+162897058e-17*h*M*d+394367674e-16*T*d+-118566247e-17*r*T*d+334678041e-18*l*T*d+-115606447e-18*I*d+-2.80626406*v+.548712484*r*v+-.0039942841*o*v+-.000954009191*p*v+193090978e-13*m*v+-.308806365*l*v+.0116952364*r*l*v+.000495271903*o*l*v+-190710882e-13*p*l*v+.00210787756*h*v+-.000698445738*r*h*v+230109073e-13*o*h*v+.00041785659*y*v+-127043871e-13*r*y*v+-304620472e-14*k*v+.0514507424*c*v+-.00432510997*r*c*v+899281156e-13*o*c*v+-714663943e-15*p*c*v+-.000266016305*l*c*v+.000263789586*r*l*c*v+-701199003e-14*o*l*c*v+-.000106823306*h*c*v+361341136e-14*r*h*c*v+229748967e-15*y*c*v+.000304788893*g*v+-642070836e-13*r*g*v+116257971e-14*o*g*v+768023384e-14*l*g*v+-547446896e-15*r*l*g*v+-35993791e-15*h*g*v+-436497725e-14*M*v+168737969e-15*r*M*v+267489271e-16*l*M*v+323926897e-17*T*v+-.0353874123*u+-.22120119*r*u+.0155126038*o*u+-.000263917279*p*u+.0453433455*l*u+-.00432943862*r*l*u+.000145389826*o*l*u+.00021750861*h*u+-666724702e-13*r*h*u+33321714e-12*y*u+-.00226921615*c*u+.000380261982*r*c*u+-545314314e-17*o*c*u+-.000796355448*l*c*u+253458034e-13*r*l*c*u+-631223658e-14*h*c*u+.000302122035*g*u+-477403547e-14*r*g*u+173825715e-14*l*g*u+-409087898e-15*M*u+.614155345*x+-.0616755931*r*x+.00133374846*o*x+.00355375387*l*x+-.000513027851*r*l*x+.000102449757*h*x+-.00148526421*c*x+-411469183e-13*r*c*x+-680434415e-14*l*c*x+-977675906e-14*g*x+.0882773108*S+-.00301859306*r*S+.00104452989*l*S+.000247090539*c*S+.00148348065*H}function oe(e){return e<-40?{label:"Extreme cold",bg:"#1a1438",fg:"#fff"}:e<-27?{label:"Very strong cold",bg:"#23408f",fg:"#fff"}:e<-13?{label:"Arctic",bg:"#3f73c4",fg:"#fff"}:e<0?{label:"Freezing",bg:"#7eb0e0",fg:"#1a1612"}:e<9?{label:"Cold",bg:"#bcd9ec",fg:"#1a1612"}:e<18?{label:"Chilled",bg:"#c8dcc0",fg:"#1a1612"}:e<26?{label:"Comfortable",bg:"#6ab05a",fg:"#fff"}:e<32?{label:"Moderate heat",bg:"#e8c547",fg:"#1a1612"}:e<38?{label:"Strong heat",bg:"#dc8a3a",fg:"#1a1612"}:e<46?{label:"Very strong heat",bg:"#c44a3a",fg:"#fff"}:{label:"Extreme heat",bg:"#7a1a1a",fg:"#fff"}}function gt({value:e,cat:t,loading:n}){const f=Array.from({length:36},(m,b)=>{const h=(b*10-90)*Math.PI/180,y=b%9===0,k=b%3===0,C=y?72:k?78:82;return{x1:100+86*Math.cos(h),y1:100+86*Math.sin(h),x2:100+C*Math.cos(h),y2:100+C*Math.sin(h),major:y,medium:k}}),r=[{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 l(m,b){const h=(135+m*270)*Math.PI/180;return[100+b*Math.cos(h),100+b*Math.sin(h)]}function c(m){const b=Math.min(1,Math.max(0,(m.min+10)/60)),w=Math.min(1,Math.max(0,(m.max+10)/60)),h=68,[y,k]=l(b,h),[C,D]=l(w,h),g=(w-b)*270>180?1:0;return`M ${y} ${k} A ${h} ${h} 0 ${g} 1 ${C} ${D}`}let d=100,o=154;if(e!=null){const w=(135+Math.min(1,Math.max(0,(e+10)/60))*270)*Math.PI/180;d=100+54*Math.cos(w),o=100+54*Math.sin(w)}const p=t?t.bg:"#c8922a";return a("svg",{viewBox:"0 0 200 200",className:"scope-ring","aria-label":"Current UTCI scope readout",children:[a("defs",{children:a("radialGradient",{id:"scope-bg-glow",cx:"50%",cy:"50%",r:"50%",children:[a("stop",{offset:"0%","stop-color":p,"stop-opacity":"0.12"}),a("stop",{offset:"100%","stop-color":p,"stop-opacity":"0"})]})}),a("circle",{cx:100,cy:100,r:"96",fill:"url(#scope-bg-glow)"}),r.map((m,b)=>a("path",{d:c(m),fill:"none",stroke:m.color,"stroke-width":"4",opacity:"0.4","stroke-linecap":"butt"},b)),a("circle",{cx:100,cy:100,r:86,fill:"none",stroke:"#c9b08a","stroke-width":"1.5"}),f.map((m,b)=>a("line",{x1:m.x1,y1:m.y1,x2:m.x2,y2:m.y2,stroke:m.major?"#c8922a":m.medium?"#c9b08a":"#e0d0b0","stroke-width":m.major?1.5:.75},b)),a("line",{x1:17,y1:100,x2:68,y2:100,stroke:"#d4b896","stroke-width":"0.75"}),a("line",{x1:132,y1:100,x2:183,y2:100,stroke:"#d4b896","stroke-width":"0.75"}),a("line",{x1:100,y1:17,x2:100,y2:68,stroke:"#d4b896","stroke-width":"0.75"}),a("line",{x1:100,y1:132,x2:100,y2:183,stroke:"#d4b896","stroke-width":"0.75"}),a("circle",{cx:100,cy:100,r:"58",fill:"none",stroke:"#e8d8c0","stroke-width":"0.75"}),a("circle",{cx:100,cy:100,r:"32",fill:"none",stroke:"#e8d8c0","stroke-width":"0.5"}),[[-1,-1],[1,-1],[-1,1],[1,1]].map(([m,b],w)=>a("g",{children:[a("line",{x1:100+m*72,y1:100+b*72,x2:100+m*60,y2:100+b*72,stroke:"#c9b08a","stroke-width":"1"}),a("line",{x1:100+m*72,y1:100+b*72,x2:100+m*72,y2:100+b*60,stroke:"#c9b08a","stroke-width":"1"})]},w)),e!=null&&a(j,{children:[a("line",{x1:100,y1:100,x2:d,y2:o,stroke:"#c8922a","stroke-width":"3","stroke-linecap":"round",opacity:"0.18"}),a("line",{x1:100,y1:100,x2:d,y2:o,stroke:"#c8922a","stroke-width":"1.5","stroke-linecap":"round",opacity:"0.95"})]}),a("circle",{cx:100,cy:100,r:"5.5",fill:"#f5edd6",stroke:"#c8922a","stroke-width":"1.5"}),a("circle",{cx:100,cy:100,r:"2.5",fill:"#c8922a"}),n?a("text",{x:100,y:105,"text-anchor":"middle",fill:"#b09870","font-size":"11","font-family":"monospace",children:"· · ·"}):e!=null?a(j,{children:[a("text",{x:100,y:92,"text-anchor":"middle",fill:"#1e1208","font-size":"26","font-family":"Fraunces, serif","font-weight":"700",children:[e.toFixed(1),"°"]}),a("text",{x:100,y:107,"text-anchor":"middle",fill:"#9a7d5a","font-size":"6.5","font-family":"JetBrains Mono, monospace","letter-spacing":"2",children:"UTCI NOW"}),a("text",{x:100,y:119,"text-anchor":"middle",fill:p,"font-size":"7","font-family":"JetBrains Mono, monospace","letter-spacing":"0.8",children:t.label.toUpperCase()})]}):a("text",{x:100,y:105,"text-anchor":"middle",fill:"#b09870","font-size":"8","font-family":"JetBrains Mono, monospace","letter-spacing":"1.2",children:"AWAITING"})]})}function bt(e,t,n){let _=0;if(e>0){const s=Math.min(7,1.4*Math.pow(e,.55)+e*.28),i=1+Math.min(.35,n*.025);_+=s*i}return t>0&&(_+=Math.min(6,2.2+t*1.6)),-Math.round(_*10)/10}function vt(){var v;const[e,t]=F({name:"Pangbourne, Berkshire",lat:51.4839,lon:-1.0725,country:"GB"}),[n,_]=F(null),[s,i]=F(!1),[f,r]=F(null),[l,c]=F(""),[d,o]=F([]),[p,m]=F(!1),[b,w]=F(0),[h,y]=F({hour:!0,air:!0,rh:!0,wind:!0,cloud:!1,sun:!1,direct:!1,diffuse:!1,tmrt:!1,delta:!1,utci:!1,utciP:!0,precip:!0}),k=u=>y(x=>({...x,[u]:!x[u]})),C=lt(null);Ae(()=>{if(l.length<2){o([]);return}C.current&&clearTimeout(C.current),C.current=setTimeout(async()=>{m(!0);try{const x=await(await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(l)}&count=6&language=en&format=json`)).json();o(x.results||[])}catch{o([])}finally{m(!1)}},300)},[l]),Ae(()=>{async function u(){i(!0),r(null);try{const x=`https://api.open-meteo.com/v1/forecast?latitude=${e.lat}&longitude=${e.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`,S=await fetch(x);if(!S.ok)throw new Error(`Open-Meteo HTTP ${S.status}`);_(await S.json())}catch(x){r(x.message)}finally{i(!1)}}u()},[e]);const D=n?n.hourly.time.map((u,x)=>{const S=n.hourly.temperature_2m[x],H=n.hourly.relative_humidity_2m[x],R=n.hourly.wind_speed_10m[x],B=n.hourly.direct_radiation[x]||0,he=n.hourly.diffuse_radiation[x]||0,fe=n.hourly.shortwave_radiation[x]||0,Ye=n.hourly.cloud_cover[x],me=n.hourly.precipitation[x]||0,pe=n.hourly.snowfall[x]||0,ye=new Date(u),ge=mt(e.lat,e.lon,ye),be=ft(S,H),ve=pt(S,B,he,fe,ge),xe=yt(S,ve,R,be),Qe=xe+bt(me,pe,R);return{iso:u,dt:ye,Ta:S,RH:H,va:R,dir:B,dif:he,glob:fe,cc:Ye,precip:me,snow:pe,elev:ge,Tmrt:ve,utci:xe,utciAdj:Qe,eh:be}}):[],g=[];D.forEach(u=>{const x=u.iso.slice(0,10);let S=g.find(H=>H.key===x);S||(S={key:x,date:new Date(u.iso),rows:[]},g.push(S)),S.rows.push(u)});const M=((v=g[b])==null?void 0:v.rows)||[],T=new Date,I=D.length>0?D.find(u=>T.toDateString()===u.dt.toDateString()&&T.getHours()===u.dt.getHours())??D.reduce((u,x)=>Math.abs(x.dt-T)c(u.target.value)}),d.length>0&&a("div",{className:"utci-results",children:d.map(u=>a("div",{className:"utci-result",onClick:()=>{t({name:`${u.name}${u.admin1?", "+u.admin1:""}`,lat:u.latitude,lon:u.longitude,country:u.country_code}),c(""),o([]),w(0)},children:[a("div",{children:[u.name,u.admin1?`, ${u.admin1}`:""]}),a("div",{className:"utci-result-meta",children:[u.country," · ",u.latitude.toFixed(2),"°, ",u.longitude.toFixed(2),"°"]})]},`${u.id}-${u.latitude}`))}),p&&a("div",{className:"utci-searching",children:"Searching…"})]})})]}),f&&a("div",{className:"utci-status",style:{borderColor:"#3a1010",color:"#c44a3a"},children:["⚠ ",f]}),s&&!f&&a("div",{className:"utci-status",children:"Acquiring forecast data…"}),n&&g.length>0&&a(j,{children:[a("div",{className:"utci-day-tabs",children:g.map((u,x)=>a("button",{className:`utci-day-tab ${x===b?"active":""}`,onClick:()=>w(x),children:[x===0?"Today":x===1?"Tomorrow":"Day after",a("span",{className:"utci-day-date",children:u.date.toLocaleDateString("en-GB",{weekday:"long",day:"numeric",month:"short"})})]},u.key))}),a("div",{className:"col-toggles",children:[a("span",{className:"col-toggles-label",children:"Columns:"}),[{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(u=>a("button",{className:`col-toggle${h[u.key]?" on":""}`,onClick:()=>k(u.key),children:u.label},u.key))]}),a("div",{className:"utci-table-wrap",children:a("table",{className:"utci-table",children:[a("thead",{children:a("tr",{children:[h.hour&&a("th",{children:"Hour"}),h.air&&a("th",{children:["Air ",a("span",{className:"col-unit",children:"°C"})]}),h.rh&&a("th",{children:["RH ",a("span",{className:"col-unit",children:"%"})]}),h.wind&&a("th",{children:["Wind ",a("span",{className:"col-unit",children:"m/s"})]}),h.cloud&&a("th",{children:["Cloud ",a("span",{className:"col-unit",children:"%"})]}),h.sun&&a("th",{children:["Sun ",a("span",{className:"col-unit",children:"elev°"})]}),h.direct&&a("th",{children:["Direct ",a("span",{className:"col-unit",children:"W/m²"})]}),h.diffuse&&a("th",{children:["Diffuse ",a("span",{className:"col-unit",children:"W/m²"})]}),h.tmrt&&a("th",{children:["Tmrt ",a("span",{className:"col-unit",children:"°C"})]}),h.delta&&a("th",{children:["Δ ",a("span",{className:"col-unit",children:"UTCI−Air"})]}),h.utci&&a("th",{children:["UTCI ",a("span",{className:"col-unit",children:"°C felt"})]}),h.precip&&a("th",{children:["Precip ",a("span",{className:"col-unit",children:"mm/h"})]}),h.utciP&&a("th",{children:["UTCI+P ",a("span",{className:"col-unit",children:"°C adj."})]})]})}),a("tbody",{children:M.map(u=>{const x=oe(u.utci),S=u.elev<0,H=T.toDateString()===u.dt.toDateString()&&T.getHours()===u.dt.getHours(),R=u.utci-u.Ta;return a("tr",{className:[S?"is-night":"",H?"is-now":""].join(" "),children:[h.hour&&a("td",{className:"utci-time",children:[H&&a("span",{className:"now-pip"}),u.dt.toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit"})]}),h.air&&a("td",{children:u.Ta.toFixed(1)}),h.rh&&a("td",{children:Math.round(u.RH)}),h.wind&&a("td",{children:u.va.toFixed(1)}),h.cloud&&a("td",{children:Math.round(u.cc)}),h.sun&&a("td",{children:u.elev>0?u.elev.toFixed(1):"—"}),h.direct&&a("td",{children:Math.round(u.dir)}),h.diffuse&&a("td",{children:Math.round(u.dif)}),h.tmrt&&a("td",{children:u.Tmrt.toFixed(1)}),h.delta&&a("td",{style:{color:R>3?"#c8601a":R<-3?"#3f73c4":"#9a7d5a",fontWeight:600},children:[R>0?"+":"",R.toFixed(1)]}),h.utci&&a("td",{children:a("span",{className:"utci-cell",style:{background:x.bg,color:x.fg},children:u.utci.toFixed(1)})}),h.precip&&a("td",{style:{color:u.snow>0?"#6090c8":u.precip>0?"#5090b0":"#c0a880"},children:u.snow>0?"❅ "+u.snow.toFixed(1)+"cm":u.precip>0?u.precip.toFixed(1):"—"}),h.utciP&&(()=>{const B=oe(u.utciAdj);return a("td",{style:{background:"rgba(180,215,250,0.10)"},children:a("span",{className:"utci-cell",style:{background:B.bg,color:B.fg},children:u.utciAdj.toFixed(1)})})})()]},u.iso)})})]})}),a("div",{className:"utci-legend",children:[a("span",{className:"utci-legend-label",children:"Thermal stress bands"}),a("div",{className:"utci-legend-row",children:[{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((u,x)=>a("span",{className:"utci-legend-item",style:{background:u.bg,color:u.fg},children:u.l},x))})]})]}),a("div",{className:"utci-about",children:[a("h2",{className:"utci-about-heading",children:"What is SunScope?"}),a("p",{className:"utci-about-text",children:["SunScope shows how the weather will actually ",a("em",{children:"feel"})," on your body — not just the air temperature. It uses the ",a("strong",{children:"Universal Thermal Climate Index (UTCI)"}),", 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 ",a("em",{children:"felt temperature"}),". 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 ",a("strong",{children:"Open-Meteo"}),", 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."]}),a("p",{className:"utci-about-text",children:["The ",a("strong",{children:"UTCI+P"})," column adds the ",a("strong",{children:"SunScope soak-factor"}),": 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 a honest, real-world comfort score for any location worldwide — simply search for your town or city and compare the three-day hourly forecast."]})]}),a("div",{className:"utci-footer",children:[a("em",{children:"Reading the table."})," 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."]})]})]})}ct(a(vt,{}),document.getElementById("root")); diff --git a/assets/sunscope.css b/assets/sunscope.css new file mode 100644 index 0000000..cca2ab6 --- /dev/null +++ b/assets/sunscope.css @@ -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; + } + +} diff --git a/assets/sunscope.js b/assets/sunscope.js new file mode 100644 index 0000000..c63910f --- /dev/null +++ b/assets/sunscope.js @@ -0,0 +1,1234 @@ +// ════════════════════════════════════════════════════════════════════════ +// 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 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`
shown when true
`} +// +// 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, useRef } from './vendor/preact-hooks.js'; +import htm from './vendor/htm.js'; + +// `html` is the magic tagged-template function. Use it like: +// html`
${someValue}
` + +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; +} + +// ═══════════════════════════════════════════════════════════════════ +// 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'; +} + +// ═══════════════════════════════════════════════════════════════════ +// 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 = 22 }) { + 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 clipId = `scope-clip-${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}`; + return html` + + + + + + + + + ${isDay + ? html` + + + + ` + : html` + + + + + `} + + + `; +} + +// ═══════════════════════════════════════════════════════════════════ +// 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 indicator (new) ────────────────────────────────────── + // The sun-or-moon glyph orbits the dial like an hour hand: + // 12 o'clock = noon · 3 o'clock = morning (east/sunrise) + // 6 o'clock = midnight · 9 o'clock = evening (west/sunset) + // A subtle sky-colour wash sits behind the readout for atmosphere. + const hour = dt.getHours() + dt.getMinutes() / 60; + const orbitAngle = -((hour - 6) / 24) * 2 * Math.PI; // radians + const orbitR = 80; // outside the temperature arcs, riding the tick band + const dnX = 100 + orbitR * Math.cos(orbitAngle); + const dnY = 100 + orbitR * Math.sin(orbitAngle); + const isDay = elev > -3; + const skyFill = skyFillForElev(elev); + const phase = moonPhaseFraction(dt); + // For the moon, use the same shadow-ellipse technique as SkyScope. + const moonR = 6; // slightly smaller so it fits the outer tick band + const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR; + const moonLitFromRight = phase < 0.5; + const moonShadowCx = dnX + (moonLitFromRight ? -phaseOffset : phaseOffset); + + const cx = 100, cy = 100, R = 86; + 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` + + + + + + + + + + + + ${stressBands.map((b, i) => html` + `)} + + ${ticks.map((t, i) => html` + `)} + + + + + + + + + ${isDay + ? html`<${Fragment}> + + + ` + : html`<${Fragment}> + + + + + `} + ${[[-1,-1],[1,-1],[-1,1],[1,1]].map(([sx, sy], i) => html` + + + + `)} + ${value != null && html` + <${Fragment}> + + + `} + + + ${loading + ? html`· · ·` + : value != null + ? html`<${Fragment}> + + ${value.toFixed(1)}° + + + UTCI NOW + + + ${cat.label.toUpperCase()} + + ` + : html` + AWAITING + `} + `; +} + +// ════════════════════════════════════════════════════════════════════════ +// 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 + + // ─── 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, 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 ────────────────────────────────────────────────── + // 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,wind_speed_10m,` + + `direct_radiation,diffuse_radiation,shortwave_radiation,cloud_cover,` + + `precipitation,snowfall` + + `&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 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 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` +
+ +
+
+ +
+ +
+
+

+ SUNScope + beta +

+
See the sun the way your body does.
+
+ Universal Thermal Climate Index + · Bröde 2012 · Open-Meteo · SunScope soak-factor +
+
+ ↳ ${location.name} + + ${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}° + +
+
+ +
+ <${ScopeReticle} + value=${currentRow?.utciAdj ?? null} + cat=${currentCat} + loading=${loading} + elev=${currentRow?.elev ?? 0} + dt=${currentRow?.dt ?? new Date()} + /> +
+ +
+
+ + setSearchQuery(e.currentTarget.value)} + /> + ${searchResults.length > 0 && html` +
+ ${searchResults.map((r) => html` +
{ + setLocation({ + name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`, + lat: r.latitude, + lon: r.longitude, + country: r.country_code, + }); + setSearchQuery(''); + setSearchResults([]); + setSelectedDay(0); + }} + > +
${r.name}${r.admin1 ? `, ${r.admin1}` : ''}
+
+ ${r.country} · ${r.latitude.toFixed(2)}°, ${r.longitude.toFixed(2)}° +
+
`)} +
`} + ${searching && html`
Searching…
`} +
+
+
+ + ${error && html` +
+ ⚠ ${error} +
`} + ${loading && !error && html` +
Acquiring forecast data…
`} + + ${forecast && days.length > 0 && html` + <${Fragment}> + +
+ ${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` + `; + })} +
+ + + ${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` +
+
+
+ 🔒 ${dayName}'s forecast is part of SunScope Pro +
+
+ Pro unlocks the full 14-day forecast, customisable columns, + and an ad-free view. +
+
+ £2 / month · launching soon +
+
+
+ + Notify me at launch + + +
+
`; + })()} + + ${(() => { + const band = confidenceBand(selectedDay); + const isOutlook = selectedDay >= 7; + return html` +
+ + Day ${selectedDay + 1} of 14 · Forecast clarity: ${band.label} + + ${isOutlook && html` + + forecast skill is reduced — treat hourly detail as trend, not precision + `} +
`; + })()} + + + ${isPro + ? html` +
+ Columns: + ${[ + { 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 => html` + `)} +
` + : html` +
+ Default columns + + customisable columns & days 4–14 are part of SunScope Pro + +
`} + + +
+ + + + ${visibleCols.hour && html``} + ${visibleCols.air && html``} + ${visibleCols.rh && html``} + ${visibleCols.wind && html``} + ${visibleCols.cloud && html``} + ${visibleCols.sun && html``} + ${visibleCols.direct && html``} + ${visibleCols.diffuse && html``} + ${visibleCols.tmrt && html``} + ${visibleCols.delta && html``} + ${visibleCols.utci && html``} + ${visibleCols.precip && html``} + ${visibleCols.utciP && html``} + + + + ${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` + + ${visibleCols.hour && html` + `} + ${visibleCols.air && html``} + ${visibleCols.rh && html``} + ${visibleCols.wind && html``} + ${visibleCols.cloud && html``} + ${visibleCols.sun && html``} + ${visibleCols.direct && html``} + ${visibleCols.diffuse && html``} + ${visibleCols.tmrt && html``} + ${visibleCols.delta && html` + `} + ${visibleCols.utci && html` + `} + ${visibleCols.precip && html` + `} + ${visibleCols.utciP && html` + `} + `; + })} + +
HourAir °CRH %Wind m/sCloud %Sun elev°Direct W/m²Diffuse W/m²Tmrt °CΔ UTCI−AirUTCI °C feltPrecip mm/hUTCI+P °C adj.
+ ${isNow && html``} + + <${SkyScope} elev=${r.elev} dt=${r.dt} size=${22} /> + ${r.dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} + + ${r.Ta.toFixed(1)}${Math.round(r.RH)}${r.va.toFixed(1)}${Math.round(r.cc)}${r.elev > 0 ? r.elev.toFixed(1) : '—'}${Math.round(r.dir)}${Math.round(r.dif)}${r.Tmrt.toFixed(1)} 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#9a7d5a', + fontWeight: 600, + }}> + ${delta > 0 ? '+' : ''}${delta.toFixed(1)} + + + ${r.utci.toFixed(1)} + + 0 ? '#6090c8' : r.precip > 0 ? '#5090b0' : '#c0a880' }}> + ${r.snow > 0 ? '❅ ' + r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'} + + + ${r.utciAdj.toFixed(1)} + +
+
+ +
+ Thermal stress bands +
+ ${[ + { 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` + + ${b.l} + `)} +
+
+ `} + +
+

What is SunScope?

+

+ SunScope shows how the weather will actually feel on your body — not just the air + temperature. It uses the Universal Thermal Climate Index (UTCI), 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 felt + temperature. 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 Open-Meteo, 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. +

+

+ The UTCI+P column adds the SunScope soak-factor: 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). +

+
+ + + +
+
`; +} + +// ════════════════════════════════════════════════════════════════════════ +// MOUNT — find the empty
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
+// 2. index.html loads this file as +
+ diff --git a/utci-forecast.jsx b/utci-forecast.jsx deleted file mode 100644 index 7a7db8d..0000000 --- a/utci-forecast.jsx +++ /dev/null @@ -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 ( - - - - - - - - - {/* Ambient glow */} - - - {/* Stress band arcs */} - {stressBands.map((b, i) => ( - - ))} - - {/* Outer ring */} - - - {/* Tick marks */} - {ticks.map((t, i) => ( - - ))} - - {/* Crosshair lines */} - - - - - - {/* Inner rings */} - - - - {/* Corner bracket markers */} - {[[-1,-1],[1,-1],[-1,1],[1,1]].map(([sx, sy], i) => ( - - - - - ))} - - {/* Needle */} - {value != null && ( - <> - - - - )} - - {/* Centre pivot */} - - - - {/* Readout text */} - {loading ? ( - · · · - ) : value != null ? ( - <> - - {value.toFixed(1)}° - - - UTCI NOW - - - {cat.label.toUpperCase()} - - - ) : ( - - AWAITING - - )} - - ); -} - -// ═══════════════════════════════════════════════════════════════════ -// 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 ( -
- -
-
- -
- -
- -
-

SUNScope beta

-
See the sun the way your body does.
-
Universal Thermal Climate Index · Bröde 2012 · Open-Meteo · SunScope soak-factor
-
- ↳ {location.name} - - {location.lat.toFixed(3)}°, {location.lon.toFixed(3)}° - -
-
- -
- -
- -
-
- - setSearchQuery(e.target.value)} - /> - {searchResults.length > 0 && ( -
- {searchResults.map((r) => ( -
{ - setLocation({ - name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`, - lat: r.latitude, - lon: r.longitude, - country: r.country_code, - }); - setSearchQuery(''); - setSearchResults([]); - setSelectedDay(0); - }} - > -
{r.name}{r.admin1 ? `, ${r.admin1}` : ''}
-
- {r.country} · {r.latitude.toFixed(2)}°, {r.longitude.toFixed(2)}° -
-
- ))} -
- )} - {searching &&
Searching…
} -
-
- -
- - {error && ( -
- ⚠ {error} -
- )} - {loading && !error && ( -
Acquiring forecast data…
- )} - - {forecast && days.length > 0 && ( - <> -
- {days.map((d, i) => ( - - ))} -
- -
- Columns: - {[ - { 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 => ( - - ))} -
- -
- - - - {visibleCols.hour && } - {visibleCols.air && } - {visibleCols.rh && } - {visibleCols.wind && } - {visibleCols.cloud && } - {visibleCols.sun && } - {visibleCols.direct && } - {visibleCols.diffuse && } - {visibleCols.tmrt && } - {visibleCols.delta && } - {visibleCols.utci && } - {visibleCols.precip && } - {visibleCols.utciP && } - - - - {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 ( - - {visibleCols.hour && } - {visibleCols.air && } - {visibleCols.rh && } - {visibleCols.wind && } - {visibleCols.cloud && } - {visibleCols.sun && } - {visibleCols.direct && } - {visibleCols.diffuse && } - {visibleCols.tmrt && } - {visibleCols.delta && } - {visibleCols.utci && } - {visibleCols.precip && } - {visibleCols.utciP && (() => { - const adjCat = utciCategory(r.utciAdj); - return ; - })()} - - ); - })} - -
HourAir °CRH %Wind m/sCloud %Sun elev°Direct W/m²Diffuse W/m²Tmrt °CΔ UTCI−AirUTCI °C feltPrecip mm/hUTCI+P °C adj.
- {isNow && } - {r.dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} - {r.Ta.toFixed(1)}{Math.round(r.RH)}{r.va.toFixed(1)}{Math.round(r.cc)}{r.elev > 0 ? r.elev.toFixed(1) : '—'}{Math.round(r.dir)}{Math.round(r.dif)}{r.Tmrt.toFixed(1)} 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#9a7d5a', - fontWeight: 600, - }}> - {delta > 0 ? '+' : ''}{delta.toFixed(1)} - - - {r.utci.toFixed(1)} - - 0 ? '#6090c8' : r.precip > 0 ? '#5090b0' : '#c0a880' }}> - {r.snow > 0 ? '❅ ' + r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'} - - - {r.utciAdj.toFixed(1)} - -
-
- -
- Thermal stress bands -
- {[ - { 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) => ( - - {b.l} - - ))} -
-
- - )} - - -
-

What is SunScope?

-

- SunScope shows how the weather will actually feel on your body — not just the air - temperature. It uses the Universal Thermal Climate Index (UTCI), 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 felt - temperature. 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 Open-Meteo, 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. -

-

- The UTCI+P column adds the SunScope soak-factor: 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 a honest, real-world comfort score for any - location worldwide — simply search for your town or city and compare the three-day - hourly forecast. -

-
-
- Reading the table. 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. -
- -
-
- ); -}