New Logo
Updated cache system now 15/30mins but scope updates every minute
Font update
This commit is contained in:
fraxle
2026-06-04 14:07:34 +01:00
parent efc9b0eed9
commit ede875f0d0
100 changed files with 231 additions and 106 deletions
+2 -5
View File
@@ -21,10 +21,7 @@
<meta name="twitter:description" content="What is UTCI? How does SunSoak work? Learn how SunScope calculates felt temperature across different environments — forest, alpine, beach, desert and more — plus urban heat, vehicle temperatures, and an honest outdoor comfort forecast." /> <meta name="twitter:description" content="What is UTCI? How does SunSoak work? Learn how SunScope calculates felt temperature across different environments — forest, alpine, beach, desert and more — plus urban heat, vehicle temperatures, and an honest outdoor comfort forecast." />
<meta name="twitter:image" content="https://sunscope.net/og-image.png" /> <meta name="twitter:image" content="https://sunscope.net/og-image.png" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☀️</text></svg>" /> <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="preconnect" href="https://fonts.bunny.net" />
<link href="https://fonts.bunny.net/css2?family=Fraunces:ital,wght@0,900;1,300&family=Manrope:wght@400;600&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="./assets/about.css"> <link rel="stylesheet" href="./assets/about.css">
@@ -59,7 +56,7 @@
<header id="site-nav"> <header id="site-nav">
<div class="nav-overlay" onclick="toggleNav(false)"></div> <div class="nav-overlay" onclick="toggleNav(false)"></div>
<a href="./index.html" class="logo"><span class="logo-sun">SUN</span><span class="logo-scope">Scope</span></a> <a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-ab"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-ab)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a>
<div class="nav-links" id="nav-drawer"> <div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a> <a href="./index.html">Forecast</a>
<a href="./about.html" class="active">About</a> <a href="./about.html" class="active">About</a>
+16 -5
View File
@@ -1,3 +1,11 @@
@font-face { font-family: 'Manrope'; font-style: normal; font-weight: 200 800; font-display: swap; src: url('fonts/manrope-variable.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-style: normal; font-weight: 700; font-display: swap; src: url('fonts/fraunces-700-normal.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-style: normal; font-weight: 900; font-display: swap; src: url('fonts/fraunces-900-normal.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-style: italic; font-weight: 300; font-display: swap; src: url('fonts/fraunces-300-italic.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-style: italic; font-weight: 700; font-display: swap; src: url('fonts/fraunces-700-italic.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-weight: 400; font-display: swap; src: url('fonts/jetbrains-mono-400.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-weight: 600; font-display: swap; src: url('fonts/jetbrains-mono-600.woff2') format('woff2'); }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
@@ -141,15 +149,18 @@ header a.active { color: #c8922a; }
.logo { .logo {
margin-right: auto; margin-right: auto;
font-family: 'Fraunces', serif; font-family: 'Manrope', sans-serif;
font-weight: 900; font-weight: 250;
font-size: 1.6rem; font-size: 1.6rem;
color: #1e1208; color: #1e1208;
letter-spacing: -0.02em; letter-spacing: -0.01em;
text-transform: none; text-transform: none;
display: flex;
align-items: center;
gap: 0.05em;
} }
.logo-sun { color: #c8922a; font-weight: 900; font-style: normal; } .logo-sun, .logo-scope { color: #1e1208; font-weight: 250; font-style: normal; }
.logo-scope { font-style: italic; font-weight: 900; color: #1e1208; } .logo-icon { width: 0.82em; height: 0.82em; flex-shrink: 0; }
main { main {
max-width: 780px; max-width: 780px;
+22 -16
View File
@@ -1,6 +1,5 @@
/* ── SELF-HOSTED FONTS ───────────────────────────────────────────────── */ /* ── SELF-HOSTED FONTS ───────────────────────────────────────────────── */
@font-face { font-family: 'Manrope'; font-style: normal; font-weight: 600; font-display: swap; src: url('../fonts/manrope-600.woff2') format('woff2'); } @font-face { font-family: 'Manrope'; font-style: normal; font-weight: 200 800; font-display: swap; src: url('../fonts/manrope-variable.woff2') format('woff2'); }
@font-face { font-family: 'Manrope'; font-style: normal; font-weight: 700; font-display: swap; src: url('../fonts/manrope-700.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-style: normal; font-weight: 700; font-display: swap; src: url('../fonts/fraunces-700-normal.woff2') format('woff2'); } @font-face { font-family: 'Fraunces'; font-style: normal; font-weight: 700; font-display: swap; src: url('../fonts/fraunces-700-normal.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-style: normal; font-weight: 900; font-display: swap; src: url('../fonts/fraunces-900-normal.woff2') format('woff2'); } @font-face { font-family: 'Fraunces'; font-style: normal; font-weight: 900; font-display: swap; src: url('../fonts/fraunces-900-normal.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-style: italic; font-weight: 300; font-display: swap; src: url('../fonts/fraunces-300-italic.woff2') format('woff2'); } @font-face { font-family: 'Fraunces'; font-style: italic; font-weight: 300; font-display: swap; src: url('../fonts/fraunces-300-italic.woff2') format('woff2'); }
@@ -127,28 +126,35 @@ body {
.nav-logo { .nav-logo {
display: none; display: none;
} }
.nav-logo-sun { .nav-logo-wordmark {
font-family: Fraunces, serif; display: flex;
font-weight: 900; align-items: center;
font-size: 2.1rem; gap: 0.05em;
color: #c8922a;
letter-spacing: -0.02em;
} }
.nav-logo-sun,
.nav-logo-scope { .nav-logo-scope {
font-family: Fraunces, serif; font-family: Manrope, sans-serif;
font-style: italic; font-weight: 250;
font-weight: 300;
font-size: 2.1rem; font-size: 2.1rem;
color: #1e1208; color: #1e1208;
letter-spacing: -0.02em; letter-spacing: -0.01em;
}
.nav-logo-icon {
width: 1.65rem;
height: 1.65rem;
color: #1e1208;
flex-shrink: 0;
} }
.nav-logo-tag { .nav-logo-tag {
display: block; display: block;
font-family: Fraunces, serif; font-family: Manrope, sans-serif;
font-style: italic; font-style: normal;
font-size: 11px; font-weight: 600;
font-size: 8px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #7a5c2a; color: #7a5c2a;
margin-top: 2px; margin-top: 3px;
} }
/* Desktop: overlay hidden */ /* Desktop: overlay hidden */
+26 -21
View File
@@ -49,36 +49,41 @@
justify-content: center; justify-content: center;
} }
/* The big "SUNScope" wordmark */ /* The big "SunScope" wordmark */
.utci-title { .utci-title {
font-family: Fraunces, serif; font-family: Manrope, sans-serif;
font-weight: 900; font-weight: 250;
font-variation-settings: "opsz" 144; font-size: clamp(48px, 6vw, 80px);
font-size: clamp(48px, 6vw, 80px); /* responsive — scales with viewport */ line-height: 1;
line-height: 0.9; letter-spacing: -0.01em;
letter-spacing: -0.03em; color: #1e1208;
display: flex;
align-items: center;
gap: 0.05em;
}
.utci-title .title-sun,
.utci-title .title-scope {
color: #1e1208; color: #1e1208;
} }
.utci-title .title-sun { /* "SUN" — bold + brass */ .title-icon {
color: #c8922a; width: 0.82em;
font-weight: 900; height: 0.82em;
font-style: normal;
}
.utci-title .title-scope { /* "Scope" — italic light, matching about page */
font-style: italic;
font-weight: 300;
color: #1e1208; color: #1e1208;
flex-shrink: 0;
} }
/* "See the sun the way..." */ /* "See the world the way..." */
.utci-tagline { .utci-tagline {
font-family: Fraunces, serif; font-family: Manrope, sans-serif;
font-style: italic; font-style: normal;
font-size: 17px; font-weight: 600;
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #7a5c2a; color: #7a5c2a;
margin-top: 14px; margin-top: 12px;
} }
/* "↳ Location, Somewhere" + the magnifier that reveals the search */ /* "↳ Location, Somewhere" + the magnifier that reveals the search */
+10
View File
@@ -875,6 +875,16 @@
margin: 0.3rem 0 0; margin: 0.3rem 0 0;
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
.utci-fetch-time.is-stale {
opacity: 0.75;
color: #a06e18;
}
.stale-dot {
font-size: 0.5rem;
color: #c8922a;
vertical-align: middle;
line-height: 1;
}
/* ── 10. LEGEND (thermal-stress band colours at the bottom) ────────── */ /* ── 10. LEGEND (thermal-stress band colours at the bottom) ────────── */
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+12 -7
View File
@@ -81,6 +81,7 @@ export function UTCIForecast() {
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll, tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
hourlyRows, days, utcOffsetMs, hourlyRows, days, utcOffsetMs,
visible, tableRows, nowLocalISO, currentRow, currentCat, visible, tableRows, nowLocalISO, currentRow, currentCat,
liveElev,
activeEvents, lensEvent, selectedDayEvents, activeEvents, lensEvent, selectedDayEvents,
bannerIndex, bannerTransition, bannerPrevIndex, bannerIndex, bannerTransition, bannerPrevIndex,
bannerVisible, bannerStageRef, bannerVisible, bannerStageRef,
@@ -203,6 +204,8 @@ export function UTCIForecast() {
// ─── 3b. PANEL COMPUTATIONS ────────────────────────────────────────── // ─── 3b. PANEL COMPUTATIONS ──────────────────────────────────────────
// Derived from currentRow and the active day - no side effects. // Derived from currentRow and the active day - no side effects.
const whyFeelsLike = computeWhyFeelsLike(currentRow ?? null, UTCI_ENVIRONMENTS[utciEnv]); const whyFeelsLike = computeWhyFeelsLike(currentRow ?? null, UTCI_ENVIRONMENTS[utciEnv]);
const staleThreshMs = isPro ? 15 * 60 * 1000 : 30 * 60 * 1000;
const isStale = fetchedAt ? (now - fetchedAt) > staleThreshMs : false;
const glanceSummary = computeGlanceSummary( const glanceSummary = computeGlanceSummary(
days[selectedDay]?.rows ?? [], days[selectedDay]?.rows ?? [],
activeProfile, activeProfile,
@@ -254,8 +257,8 @@ export function UTCIForecast() {
<nav class="utci-topnav" id="site-nav"> <nav class="utci-topnav" id="site-nav">
<div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div> <div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div>
<div class="nav-logo" aria-hidden="true"> <div class="nav-logo" aria-hidden="true">
<span class="nav-logo-wordmark"><span class="nav-logo-sun">SUN</span><span class="nav-logo-scope">Scope</span></span> <span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-n"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-n)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
<span class="nav-logo-tag">See the sun the way your skin does.</span> <span class="nav-logo-tag">See the world the way your skin does.</span>
</div> </div>
<div class="nav-links" id="nav-drawer"> <div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a> <a href="./index.html">Forecast</a>
@@ -347,9 +350,9 @@ export function UTCIForecast() {
<div class="utci-header"> <div class="utci-header">
<div class="header-left"> <div class="header-left">
<h1 class="utci-title"> <h1 class="utci-title">
<span class="title-sun">SUN</span><span class="title-scope">Scope</span> <span class="title-sun">Sun</span><svg class="title-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-t"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-t)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="title-scope">Scope</span>
</h1> </h1>
<div class="utci-tagline">See the sun the way your skin does.</div> <div class="utci-tagline">See the world the way your skin does.</div>
<div class="utci-loc-search" ref=${searchWrapRef}> <div class="utci-loc-search" ref=${searchWrapRef}>
<div class="utci-current-loc"> <div class="utci-current-loc">
<button <button
@@ -382,7 +385,9 @@ export function UTCIForecast() {
</span> </span>
</div> </div>
${fetchedAt && !loading && html` ${fetchedAt && !loading && html`
<div class="utci-fetch-time">Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>`} <div class=${'utci-fetch-time' + (isStale ? ' is-stale' : '')}>
${isStale ? html`<span class="stale-dot" title="Data is older than expected - retrying">● </span>` : ''}Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>`}
${searchOpen && html` ${searchOpen && html`
<div class="utci-search-wrap utci-search-overlay"> <div class="utci-search-wrap utci-search-overlay">
<input <input
@@ -428,8 +433,8 @@ export function UTCIForecast() {
value=${currentRow?.utciAdj ?? null} value=${currentRow?.utciAdj ?? null}
cat=${currentCat} cat=${currentCat}
loading=${loading} loading=${loading}
elev=${currentRow?.elev ?? 0} elev=${liveElev ?? currentRow?.elev ?? 0}
dt=${currentRow?.dt ?? new Date()} dt=${now}
glob=${currentRow?.glob ?? 0} glob=${currentRow?.glob ?? 0}
activeEvent=${lensEvent} activeEvent=${lensEvent}
utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null} utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null}
+11 -1
View File
@@ -390,8 +390,18 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
return html` return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`} <svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}> style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
${category === 'clear' && html` ${category === 'clear' && elev >= 0 && html`
${sun(size * 0.50, size * 0.38, size * 0.16)}`} ${sun(size * 0.50, size * 0.38, size * 0.16)}`}
${category === 'clear' && elev < 0 && html`
<path d=${`M ${size*0.45} ${size*0.15}
A ${size*0.20} ${size*0.20} 0 1 1 ${size*0.45} ${size*0.55}
A ${size*0.235} ${size*0.235} 0 1 0 ${size*0.45} ${size*0.15} Z`}
fill=${brass} opacity="0.85" />
<circle cx=${size*0.82} cy=${size*0.22} r=${size*0.030} fill=${brass} opacity="0.75" />
<circle cx=${size*0.20} cy=${size*0.28} r=${size*0.022} fill=${brass} opacity="0.65" />
<circle cx=${size*0.75} cy=${size*0.60} r=${size*0.025} fill=${brass} opacity="0.70" />
<circle cx=${size*0.18} cy=${size*0.66} r=${size*0.018} fill=${brass} opacity="0.55" />
<circle cx=${size*0.88} cy=${size*0.48} r=${size*0.018} fill=${brass} opacity="0.55" />`}
${category === 'wispy' && html` ${category === 'wispy' && html`
${mist(size * 0.18, brass)} ${mist(size * 0.18, brass)}
${line(size * 0.18, size * 0.56, size * 0.60, size * 0.56, muted, sw * 0.7, 0.6)}`} ${line(size * 0.18, size * 0.56, size * 0.60, size * 0.56, muted, sw * 0.7, 0.6)}`}
+5 -5
View File
@@ -395,17 +395,17 @@ export function DayTabs({
${dayHi !== null && html` ${dayHi !== null && html`
<span style=${{ <span style=${{
display: 'block', display: 'block',
fontFamily: 'Fraunces, serif', fontFamily: 'Manrope, sans-serif',
fontStyle: 'italic', fontStyle: 'normal',
fontSize: '11px', fontSize: '13px',
marginTop: '2px', marginTop: '2px',
letterSpacing: 0, letterSpacing: 0,
textTransform: 'none', textTransform: 'none',
textShadow: '0 0 14px rgba(255,255,255,0.85), 0 0 11px rgba(255,255,255,0.7), 0 0 8px rgba(255,255,255,0.6), 0 0 5px rgba(255,255,255,0.5)', textShadow: '0 0 14px rgba(255,255,255,0.85), 0 0 11px rgba(255,255,255,0.7), 0 0 8px rgba(255,255,255,0.6), 0 0 5px rgba(255,255,255,0.5)',
}}> }}>
<span style=${{ color: '#c0622a', fontWeight: 600 }}>${dayHi}°</span> <span style=${{ color: '#c0622a', fontWeight: 800 }}>${dayHi}°</span>
<span style=${{ opacity: 0.5 }}> / </span> <span style=${{ opacity: 0.5 }}> / </span>
<span style=${{ color: '#3a6080' }}>${dayLo}°</span> <span style=${{ color: '#3a6080', fontWeight: 800 }}>${dayLo}°</span>
</span>`} </span>`}
</button>`; </button>`;
})} })}
+56 -19
View File
@@ -116,11 +116,41 @@ export function checkHeatSpike(rows) {
// wet + calm → rain icon (🌧️) // wet + calm → rain icon (🌧️)
// windy + wet → storm icon (🌩️), and the wind/rain icons are suppressed // windy + wet → storm icon (🌩️), and the wind/rain icons are suppressed
// for that hour so a single hour never shows two icons. // for that hour so a single hour never shows two icons.
const GUST_BLUSTERY = 25 / 2.237; // m/s (= 25 mph) - matches the table's gust highlight const GUST_BLUSTERY = 25 / 2.237; // m/s (= 25 mph) - Beaufort Force 6 threshold
const GUST_STORM = 20; // m/s ≈ 45 mph - storm-force const GUST_STORM = 20; // m/s ≈ 45 mph - Beaufort Force 8+ threshold
const PRECIP_WET = 5; // mm/h - meaningful rain const PRECIP_WET = 5; // mm/h - meaningful rain
const PRECIP_HEAVY = 10; // mm/h - heavy rain const PRECIP_HEAVY = 10; // mm/h - heavy rain
// Beaufort scale lookup - force number and name from gust speed in m/s
function beaufortForce(ms) {
const mph = ms * 2.237;
if (mph >= 73) return { force: 12, name: 'Hurricane Force' };
if (mph >= 64) return { force: 11, name: 'Violent Storm' };
if (mph >= 55) return { force: 10, name: 'Storm Force' };
if (mph >= 47) return { force: 9, name: 'Strong Gale' };
if (mph >= 39) return { force: 8, name: 'Gale' };
if (mph >= 32) return { force: 7, name: 'Near Gale' };
return { force: 6, name: 'Strong Breeze' };
}
const BEAUFORT_DESC = {
6: 'Large branches in motion; umbrellas used with difficulty.',
7: 'Whole trees in motion; inconvenience felt when walking against the wind.',
8: 'Twigs break off trees and generally impedes progress.',
9: 'Slight structural damage possible — chimney pots and slates at risk.',
10: 'Trees uprooted; considerable structural damage expected.',
11: 'Very rarely experienced inland; widespread damage expected.',
12: 'Devastating conditions — avoid all outdoor activity.',
};
// Banner colour scales with severity
function windBannerColors(force) {
if (force >= 11) return { color: '#0c1020', textColor: '#c0d8ff' };
if (force >= 9) return { color: '#101828', textColor: '#c0d4f0' };
if (force >= 7) return { color: '#141e2e', textColor: '#c0d0e8' };
return { color: '#1a2030', textColor: '#c0d8f0' };
}
const gustOf = r => r.gust ?? r.va ?? 0; const gustOf = r => r.gust ?? r.va ?? 0;
const precipOf = r => r.precip ?? 0; const precipOf = r => r.precip ?? 0;
const isWindyHour = r => { const g = gustOf(r); return isFinite(g) && g >= GUST_BLUSTERY; }; const isWindyHour = r => { const g = gustOf(r); return isFinite(g) && g >= GUST_BLUSTERY; };
@@ -135,20 +165,24 @@ export function checkWind(rows) {
const maxGust = Math.max(...affected.map(gustOf).filter(isFinite)); const maxGust = Math.max(...affected.map(gustOf).filter(isFinite));
const mph = Math.round(maxGust * 2.237); const mph = Math.round(maxGust * 2.237);
const isStorm = maxGust >= GUST_STORM; const { force, name } = beaufortForce(maxGust);
const desc = BEAUFORT_DESC[force] || '';
const { color, textColor } = windBannerColors(force);
const title = force >= 10 ? `${name} Winds`
: force >= 8 ? `${name} Warning`
: name;
return { return {
id: 'wind', id: 'wind',
emoji: '💨', emoji: force >= 10 ? '🌪️' : '💨',
title: isStorm ? 'Storm-Force Winds' : 'Blustery Conditions', title,
message: isStorm message: `Gusts to ${mph} mph (Beaufort Force ${force}). ${desc}`,
? `Storm-force gusts to ${mph} mph expected — secure loose objects and take care outdoors.` color,
: `Windy spell — gusts to ${mph} mph at peak. Hold onto hats and secure loose items outdoors.`, textColor,
color: '#1a2030',
textColor: '#c0d8f0',
type: 'weather', type: 'weather',
nightOnly: false, nightOnly: false,
isoRange, // only show cell icon during the windy hours isoRange,
}; };
} }
@@ -185,20 +219,23 @@ export function checkStorm(rows) {
const maxGust = Math.max(...affected.map(gustOf).filter(isFinite)); const maxGust = Math.max(...affected.map(gustOf).filter(isFinite));
const maxPrecip = Math.max(...affected.map(precipOf).filter(isFinite)); const maxPrecip = Math.max(...affected.map(precipOf).filter(isFinite));
const mph = Math.round(maxGust * 2.237); const mph = Math.round(maxGust * 2.237);
const isSevere = maxGust >= GUST_STORM || maxPrecip >= PRECIP_HEAVY; const { force, name } = beaufortForce(maxGust);
const { color, textColor } = windBannerColors(force);
const title = force >= 10 ? `${name} — Severe Storm`
: force >= 8 ? `${name} with Rain`
: 'Storm Conditions';
return { return {
id: 'storm', id: 'storm',
emoji: '🌩️', emoji: '🌩️',
title: isSevere ? 'Severe Storm' : 'Storm Conditions', title,
message: isSevere message: `Gusts to ${mph} mph (Beaufort Force ${force}) with ${maxPrecip.toFixed(1)} mm/h rain. ${BEAUFORT_DESC[force] || ''} Take care outdoors.`,
? `Severe storm — driving rain to ${maxPrecip.toFixed(1)} mm/h and gusts to ${mph} mph. Avoid travel if you can.` color,
: `Stormy spell — wind and rain together, gusts to ${mph} mph with ${maxPrecip.toFixed(1)} mm/h rain. Take care outdoors.`, textColor,
color: '#141a26',
textColor: '#c0d0e8',
type: 'weather', type: 'weather',
nightOnly: false, nightOnly: false,
isoRange, // only show cell icon during the stormy hours isoRange,
}; };
} }
+2 -1
View File
@@ -64,7 +64,7 @@ export function useAppState() {
return localStorage.getItem('sunscope_pro') === '1'; return localStorage.getItem('sunscope_pro') === '1';
}); });
const { forecast, airQuality, loading, error, now, fetchedAt } = useForecast(location, isPro); const { forecast, airQuality, loading, error, now, fetchedAt, liveElev } = useForecast(location, isPro);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]); const [searchResults, setSearchResults] = useState([]);
@@ -637,6 +637,7 @@ export function useAppState() {
// computation // computation
hourlyRows, days, utcOffsetMs, hourlyRows, days, utcOffsetMs,
visible, tableRows, nowLocalISO, currentRow, currentCat, visible, tableRows, nowLocalISO, currentRow, currentCat,
liveElev,
// banner + events // banner + events
activeEvents, lensEvent, selectedDayEvents, activeEvents, lensEvent, selectedDayEvents,
bannerIndex, bannerTransition, bannerPrevIndex, bannerIndex, bannerTransition, bannerPrevIndex,
+28 -15
View File
@@ -32,11 +32,13 @@
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
import { useState, useEffect, useRef } from '../../vendor/preact-hooks.js'; import { useState, useEffect, useRef } from '../../vendor/preact-hooks.js';
import { solarElevationDeg } from '../physics.js';
const FIVE_MIN_MS = 5 * 60 * 1000; const FIVE_MIN_MS = 5 * 60 * 1000;
const FIFTEEN_MIN_MS = 15 * 60 * 1000; const FIFTEEN_MIN_MS = 15 * 60 * 1000;
const THIRTY_MIN_MS = 30 * 60 * 1000;
const SIX_HOURS_MS = 6 * 60 * 60 * 1000; const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
const TWO_MIN_MS = 2 * 60 * 1000; const ONE_MIN_MS = 1 * 60 * 1000;
function buildForecastUrl(loc) { function buildForecastUrl(loc) {
return `https://api.open-meteo.com/v1/forecast` + return `https://api.open-meteo.com/v1/forecast` +
@@ -137,7 +139,7 @@ export function useForecast(location, isPro = false) {
// ─── FORECAST LOADER ────────────────────────────────────────────── // ─── FORECAST LOADER ──────────────────────────────────────────────
// //
// Waterfall: // Waterfall:
// 1. Cache fresh (< 5 min) → serve immediately, no fetch needed // 1. Cache fresh (< refresh interval) → serve immediately, no fetch needed
// 2. Cache stale / missing → fetch main auto API // 2. Cache stale / missing → fetch main auto API
// 3. Main API soil is null → try ICON for soil data // 3. Main API soil is null → try ICON for soil data
// 4. ICON fails → fall back to stale cache (any age) // 4. ICON fails → fall back to stale cache (any age)
@@ -148,21 +150,26 @@ export function useForecast(location, isPro = false) {
const key = forecastCacheKey(loc); const key = forecastCacheKey(loc);
let staleCache = null; let staleCache = null;
// Step 1 — serve from cache if fresh enough // Step 1 — stale-while-revalidate: serve any cached data immediately,
// then continue to fetch fresh data silently in the background.
// Only show the loading spinner if there is nothing cached at all.
const cacheMaxAge = isPro ? FIFTEEN_MIN_MS : THIRTY_MIN_MS;
try { try {
const cached = localStorage.getItem(key); const cached = localStorage.getItem(key);
if (cached) { if (cached) {
const { ts, data } = JSON.parse(cached); const { ts, data } = JSON.parse(cached);
if (Date.now() - ts < FIVE_MIN_MS) { staleCache = { ts, data };
// Always show cached data immediately - no spinner
setForecast(data); setForecast(data);
setFetchedAt(new Date(ts)); setFetchedAt(new Date(ts));
return; // If fresh enough, no need to re-fetch
} if (Date.now() - ts < cacheMaxAge) return;
staleCache = { ts, data }; // keep for step 4
} }
} catch (e) { /* ignore bad cache */ } } catch (e) { /* ignore bad cache */ }
setLoading(true); setError(null); // Only show spinner if nothing could be served from cache
if (!staleCache) { setLoading(true); }
setError(null);
try { try {
// Step 2 — fetch main forecast // Step 2 — fetch main forecast
const r = await fetch(buildForecastUrl(loc)); const r = await fetch(buildForecastUrl(loc));
@@ -231,17 +238,23 @@ export function useForecast(location, isPro = false) {
loadAirQuality(location); loadAirQuality(location);
}, [location]); }, [location]);
// ─── 2-MINUTE CLOCK TICK (current-row highlight only) ───────────── // ─── 1-MINUTE CLOCK TICK (current-row highlight + live scope) ─────
useEffect(() => { useEffect(() => {
const id = setInterval(() => setNow(new Date()), TWO_MIN_MS); const id = setInterval(() => setNow(new Date()), ONE_MIN_MS);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
// ─── AUTO REFRESH (Pro: 5 min, free: 15 min) ───────────────────── // ─── LIVE SOLAR ELEVATION (updates every minute with now tick) ─────
// Open-Meteo updates roughly every 15 min; Pro users get a tighter const [liveElev, setLiveElev] = useState(0);
// poll so the current-conditions row stays fresher.
useEffect(() => { useEffect(() => {
const refreshMs = isPro ? FIVE_MIN_MS : FIFTEEN_MIN_MS; if (location?.lat == null) return;
setLiveElev(solarElevationDeg(location.lat, location.lon, now));
}, [now, location]);
// ─── AUTO REFRESH (Pro: 15 min, free: 30 min) ─────────────────────
// Open-Meteo updates roughly every 15 min; Pro gets tighter polling.
useEffect(() => {
const refreshMs = isPro ? FIFTEEN_MIN_MS : THIRTY_MIN_MS;
const id = setInterval(() => { const id = setInterval(() => {
async function refresh() { async function refresh() {
const key = forecastCacheKey(location); const key = forecastCacheKey(location);
@@ -263,5 +276,5 @@ export function useForecast(location, isPro = false) {
return () => clearInterval(id); return () => clearInterval(id);
}, [location, isPro]); }, [location, isPro]);
return { forecast, airQuality, loading, error, now, fetchedAt }; return { forecast, airQuality, loading, error, now, fetchedAt, liveElev };
} }
+17 -4
View File
@@ -171,14 +171,27 @@ async function build() {
} }
} }
// 6. Copy fonts // 6. Copy fonts - explicit allowlist keeps only what is referenced in CSS.
// All variants remain in assets/fonts/ as source files.
const FONT_ALLOWLIST = new Set([
'manrope-variable.woff2',
'fraunces-300-italic.woff2',
'fraunces-700-normal.woff2',
'fraunces-700-italic.woff2',
'fraunces-900-normal.woff2',
'jetbrains-mono-400.woff2',
'jetbrains-mono-600.woff2',
]);
console.log(' Copying fonts...'); console.log(' Copying fonts...');
const fontsDir = path.join(ROOT, 'assets', 'fonts'); const fontsDir = path.join(ROOT, 'assets', 'fonts');
if (fs.existsSync(fontsDir)) { if (fs.existsSync(fontsDir)) {
let copied = 0;
for (const f of fs.readdirSync(fontsDir)) { for (const f of fs.readdirSync(fontsDir)) {
copyFile(path.join(fontsDir, f), path.join(DIST, 'fonts', f)); if (!FONT_ALLOWLIST.has(f)) continue;
copyFile(path.join(fontsDir, f), path.join(DIST, 'assets', 'fonts', f));
copied++;
} }
console.log(' -> fonts/ (' + fs.readdirSync(fontsDir).length + ' files)'); console.log(' -> assets/fonts/ (' + copied + ' of ' + fs.readdirSync(fontsDir).length + ' files)');
} }
// 7. Copy images // 7. Copy images
@@ -201,7 +214,7 @@ async function build() {
// 8. Copy static files // 8. Copy static files
console.log(' Copying static files...'); console.log(' Copying static files...');
for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png']) { for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg']) {
const src = path.join(ROOT, f); const src = path.join(ROOT, f);
if (fs.existsSync(src)) { if (fs.existsSync(src)) {
copyFile(src, path.join(DIST, f)); copyFile(src, path.join(DIST, f));
+2 -4
View File
@@ -16,9 +16,7 @@
<meta name="twitter:title" content="SunScope FAQ - Frequently Asked Questions" /> <meta name="twitter:title" content="SunScope FAQ - Frequently Asked Questions" />
<meta name="twitter:description" content="Plain answers to the most common questions about outdoor comfort, UV safety, car heat, air quality, pollen, and how SunScope works." /> <meta name="twitter:description" content="Plain answers to the most common questions about outdoor comfort, UV safety, car heat, air quality, pollen, and how SunScope works." />
<meta name="twitter:image" content="https://sunscope.net/og-image.png" /> <meta name="twitter:image" content="https://sunscope.net/og-image.png" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#x2600;</text></svg>" /> <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="preconnect" href="https://fonts.bunny.net" />
<link href="https://fonts.bunny.net/css2?family=Fraunces:ital,wght@0,900;1,300&family=Manrope:wght@400;600&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="./assets/about.css"> <link rel="stylesheet" href="./assets/about.css">
<script type="application/ld+json"> <script type="application/ld+json">
{ {
@@ -72,7 +70,7 @@
<header id="site-nav"> <header id="site-nav">
<div class="nav-overlay" onclick="toggleNav(false)"></div> <div class="nav-overlay" onclick="toggleNav(false)"></div>
<a href="./index.html" class="logo"><span class="logo-sun">SUN</span><span class="logo-scope">Scope</span></a> <a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-fq"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-fq)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a>
<div class="nav-links" id="nav-drawer"> <div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a> <a href="./index.html">Forecast</a>
<a href="./about.html">About</a> <a href="./about.html">About</a>
+19
View File
@@ -0,0 +1,19 @@
<svg viewBox="0 0 174 173" xmlns="http://www.w3.org/2000/svg" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)">
<clipPath id="c">
<circle cx="681.9838" cy="199.4502" r="83.2987"/>
</clipPath>
<g clip-path="url(#c)">
<g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)">
<path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/>
</g>
<g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)">
<path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="#1e1208" stroke-width="13.64"/>
</g>
<g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)">
<path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="#1e1208" stroke-width="13.64"/>
</g>
</g>
<circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="#1e1208" stroke-width="9.13"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1 -1
View File
@@ -69,7 +69,7 @@
</script> </script>
<link rel="preconnect" href="https://api.open-meteo.com" crossorigin /> <link rel="preconnect" href="https://api.open-meteo.com" crossorigin />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☀️</text></svg>" /> <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="stylesheet" href="./assets/sunscope.css?v=15g"> <link rel="stylesheet" href="./assets/sunscope.css?v=15g">
</head> </head>
<body> <body>