1.44 - New Filters, profiles & Mobile fixes

This commit is contained in:
fraxle
2026-05-17 16:01:34 +01:00
parent bc4fd3e6f1
commit 1cd2cd0ab4
11 changed files with 640 additions and 298 deletions
+1 -2
View File
@@ -31,7 +31,6 @@
JetBrains Mono numeric monospace table data
════════════════════════════════════════════════════════════════════════ */
/* ── 1. FONTS + RESET ───────────────────────────────────────────────── */
/* Font loading is handled in index.html via a non-blocking <link rel="preload">
@@ -177,7 +176,7 @@ body {
/* ── Mobile nav drawer ──────────────────────────────────────────────── */
@media (max-width: 640px) {
.utci-app {
padding: 88px 16px 24px; /* 88px = 72px nav height + 16px breathing room */
padding: 96px 16px 24px; /* 96px = 72px nav height + 24px breathing room */
}
/* Show wordmark in nav bar, hide it from main content */
-33
View File
@@ -2,7 +2,6 @@
header.css — Page header: title wordmark, UTCI dial, and search box.
════════════════════════════════════════════════════════════════════════ */
/* ── 4. HEADER (title + dial + search, three-column grid) ──────────── */
.utci-header {
@@ -45,15 +44,6 @@
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;
@@ -63,29 +53,6 @@
margin-top: 14px;
}
/* The credits line under the tagline */
.utci-subtitle {
font-family: Manrope, sans-serif;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
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;
+103 -89
View File
@@ -3,7 +3,6 @@
now-row and night-row decorations.
════════════════════════════════════════════════════════════════════════ */
/* ── 5. DAY TABS (today / tomorrow / etc.) ─────────────────────────── */
/* Note: each tab's background colour comes from inline styles in
app.js (the confidence bands). This file only handles spacing,
@@ -241,31 +240,6 @@
color: #fff;
}
/* Burn select — unlike regular col-toggle buttons, the select never
goes full-brass background so the native dropdown list stays readable.
Active state = brass border + brass text on parchment.
Inactive state = same as a regular col-toggle pill. */
.burn-select {
cursor: pointer;
outline: none;
}
/* Override the inherited .col-toggle.on brass background */
.col-toggle.burn-select.on {
background: #f5edd6;
color: #c8922a;
border-color: #c8922a;
font-weight: 600;
}
.col-toggle.burn-select.on:hover {
background: #fef3dc;
color: #c8922a;
border-color: #c8922a;
}
.burn-select:focus {
border-color: #c8922a;
outline: none;
}
/* ── Vehicle group: select + windows-open checkbox fused together ── */
.col-toggle-group {
display: inline-flex;
@@ -287,69 +261,12 @@
}
.col-toggle-group .grouped-left {
border-right: none !important;
border-radius: 20px 0 0 20px !important;
border-radius: 0 !important;
}
.col-toggle-group .cs-wrap:only-child .grouped-left,
.col-toggle-group .cs-wrap:last-child .grouped-left {
border-right: 1.5px solid #c9b08a !important;
border-radius: 20px !important;
}
/* The checkbox pill — same height/padding as a col-toggle but styled as a label */
.col-toggle-vent {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px 5px 10px;
font-size: 12px;
font-family: Manrope, sans-serif;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
cursor: pointer;
border: 1.5px solid #c9b08a;
border-left: 1px solid #d4c0a0; /* softer join line */
border-radius: 0 20px 20px 0;
background: #fef8ee;
color: #6b4228;
user-select: none;
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.col-toggle-vent:hover {
border-color: #c8922a;
color: #6b4f2a;
background: #fef3dc;
}
.col-toggle-vent.on {
background: #c8922a;
border-color: #c8922a;
color: #fff;
}
/* Custom checkbox square inside the vent label */
.vent-checkbox {
display: inline-block;
width: 12px;
height: 12px;
border: 1.5px solid currentColor;
border-radius: 3px;
background: transparent;
flex-shrink: 0;
position: relative;
transition: background 0.12s;
}
.vent-checkbox.checked::after {
content: '';
position: absolute;
left: 2px;
top: -1px;
width: 5px;
height: 8px;
border: 2px solid currentColor;
border-top: none;
border-left: none;
transform: rotate(45deg);
border-radius: 0 !important;
}
@@ -636,10 +553,6 @@
.col-info-close:hover {
color: #4a3420;
}
then sit with balanced visual padding on either side. */
.utci-table th.utci-tight-head {
letter-spacing: 0.04em;
}
/* ── 8. NOW-ROW & NIGHT-ROW DECORATIONS ─────────────────────────────── */
@@ -715,3 +628,104 @@
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* ── FILTER PROFILES + COLUMN TOGGLES — mobile fixes ───────────────── */
@media (max-width: 640px) {
/* ── Profile bar ────────────────────────────────────────────────────
Keep flex-wrap but make every child a consistent ~31% width so
they land in tidy rows of 3. The label gets its own full row. */
.filter-profiles {
flex-wrap: wrap;
gap: 6px;
padding: 10px 10px;
align-items: stretch;
}
.filter-profiles-label {
flex: 0 0 100%; /* own row */
margin-right: 0;
margin-bottom: 0;
}
/* Hide vertical pipe dividers — not needed when wrapping */
.profile-divider {
display: none;
}
/* Plain profile buttons: fixed ~31% so 3 fit per row */
.profile-btn {
flex: 0 0 calc(33.33% - 4px);
text-align: center;
padding: 7px 2px;
font-size: 11px;
letter-spacing: 0.03em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* CustomSelect (Activities / Places dropdowns) — same width as buttons */
.filter-profiles .cs-wrap {
flex: 0 0 calc(33.33% - 4px);
}
.filter-profiles .cs-btn {
width: 100%;
padding: 7px 6px;
font-size: 11px;
letter-spacing: 0.03em;
justify-content: center;
}
/* ── Column toggles bar ─────────────────────────────────────────────
Same approach — label full row, toggles at ~31% each. */
.col-toggles {
flex-wrap: wrap;
gap: 6px;
padding: 10px 10px;
align-items: stretch;
}
.col-toggles-label {
flex: 0 0 100%;
margin-right: 0;
margin-bottom: 0;
}
.col-toggle {
flex: 0 0 calc(33.33% - 4px);
text-align: center;
padding: 7px 2px;
font-size: 11px;
letter-spacing: 0.03em;
}
/* Pollen CustomSelect in col-toggles — match toggle width */
.col-toggles .cs-wrap {
flex: 0 0 calc(33.33% - 4px);
}
.col-toggles .cs-btn {
width: 100%;
padding: 7px 6px;
font-size: 11px;
letter-spacing: 0.03em;
justify-content: center;
}
/* Fused vehicle group (select + vent pill): spans 2 slots = ~65% */
.col-toggle-group {
flex: 0 0 calc(66.66% - 1px);
display: inline-flex;
align-items: stretch;
}
.col-toggle-group .cs-wrap {
flex: 1;
}
.col-toggle-group .cs-btn {
width: 100%;
font-size: 11px;
padding: 7px 6px;
justify-content: center;
}
}
+50 -10
View File
@@ -2,7 +2,6 @@
ui.css — Status banners, legend, about block, footer, and responsive.
════════════════════════════════════════════════════════════════════════ */
/* ── EVENT BANNER ───────────────────────────────────────────────────── */
/* Animated slide-down banner for cosmic/weather/promo events. */
@@ -196,9 +195,8 @@
.almanac-entry-desc {
font-family: Manrope, sans-serif;
font-size: 12px;
color: '#4a3420';
line-height: 1.55;
color: #4a3420;
line-height: 1.55;
}
.almanac-entry-visibility {
display: inline-block;
@@ -236,6 +234,51 @@
}
}
/* ── EVENT BANNER — mobile fixes ────────────────────────────────────── */
@media (max-width: 640px) {
.event-banner-wrap.visible {
max-height: 260px; /* generous — banner can be tall with dates line */
}
.event-banner-wrap {
margin-top: 8px; /* push banner clear of the fixed nav bar */
margin-bottom: 18px; /* breathing room between banner and header */
}
.event-banner {
align-items: flex-start; /* stop dismiss ✕ drifting downward */
gap: 10px;
padding: 18px 12px 14px 14px;
margin-bottom: 0;
border-radius: 4px;
}
.event-banner-emoji {
font-size: 20px;
margin-top: 3px; /* visually balance the dates-line margin-top below */
}
.event-banner-title {
font-size: 15px;
line-height: 1.25;
margin-bottom: 3px;
}
.event-banner-msg {
font-size: 12px;
line-height: 1.5;
}
.event-banner-dismiss {
margin-top: 0; /* keep ✕ pinned flush to the top-right */
padding: 0 2px;
font-size: 16px;
line-height: 1.4;
flex-shrink: 0;
align-self: flex-start;
}
}
/* ── CUSTOM SELECT DROPDOWN ─────────────────────────────────────────── */
/* Cross-platform replacement for native <select> on the col-toggles bar */
@@ -252,6 +295,7 @@
padding: 5px 10px 5px 13px;
cursor: pointer;
border: 1.5px solid #c9b08a;
border-radius: 0;
background: #f5edd6;
color: #6b4228;
font-family: Manrope, sans-serif;
@@ -299,19 +343,15 @@
transform: rotate(225deg);
margin-bottom: -1px;
}
.cs-wrap.open .cs-arrow-old {
transform: rotate(180deg);
}
/* Grouped left (fused with checkbox pill) */
.cs-btn.grouped-left {
border-right: none;
border-radius: 20px 0 0 20px;
border-radius: 0;
}
.cs-btn.grouped-left:only-child,
.cs-btn.grouped-left.last-child {
border-right: 1.5px solid #c9b08a;
border-radius: 20px;
border-radius: 0;
}
/* The floating dropdown panel */
@@ -384,7 +424,7 @@
border-left: none;
margin-left: -1px;
padding-left: 11px;
border-radius: 0 20px 20px 0;
border-radius: 0;
background: #f5edd6;
color: #6b4228;
user-select: none;
+236 -45
View File
@@ -58,6 +58,7 @@ export function UTCIForecast() {
};
const [forecast, setForecast] = useState(null); // raw Open-Meteo response
const [airQuality, setAirQuality] = useState(null); // raw Open-Meteo air quality 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
@@ -117,11 +118,10 @@ export function UTCIForecast() {
el.scrollBy({ left: dir * 200, behavior: 'smooth' });
};
// ─── PRO TIER STUB ────────────────────────────────────────────────────
// FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE.
// When this is wired to real billing/auth, replace `useState(false)`
// with a check against the logged-in user.
//const [isPro, setIsPro] = useState(false);
// ─── PRO TIER ─────────────────────────────────────────────────────────
// Initialise from URL param (?pro=1, set by Stripe after checkout) or
// from localStorage (returning subscriber). Cleans the URL param
// immediately so it doesn't stay visible in the address bar.
const [isPro, setIsPro] = useState(() => {
// Check URL param first (just returned from Stripe checkout)
const params = new URLSearchParams(window.location.search);
@@ -145,53 +145,53 @@ export function UTCIForecast() {
basic: {
label: 'Basic',
icon: '🌡️',
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false },
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false },
},
home: {
label: 'Home',
icon: '🏠',
cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true },
cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true, vis: false, aqi: true, pollen: true },
},
vehicle: {
label: 'Vehicle',
icon: '🚗',
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: true, indoorT: false, managedT: false },
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false },
},
farming: {
label: 'Farming',
icon: '🌾',
cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false },
cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true },
},
outdoors: {
label: 'Places',
icon: '🌤️',
// Default cols match the first variant (urban). Switching variant updates visibleCols.
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false },
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false },
},
alltemps: {
label: 'Temps',
icon: '🌡️',
proOnly: true,
cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: false, soilT: true, soilT6: false, soilM: false, concreteT: true, vehicleT: true, indoorT: true, managedT: true },
cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: false, soilT: true, soilT6: false, soilM: false, concreteT: true, vehicleT: true, indoorT: true, managedT: true, vis: false, aqi: false, pollen: false },
},
custom: {
label: 'Custom',
icon: '⚙️',
proOnly: true,
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false },
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false },
},
};
// Places sub-variants — each has its own column set.
// Selecting a variant applies its cols to visibleCols.
const OUTDOORS_VARIANTS = {
urban: { name: 'Urban', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false } },
beach: { name: 'Beach', cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } },
events: { name: 'Events', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false } },
festival: { name: 'Festival', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false } },
wintersports: { name: 'Winter Sports', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } },
naturist: { name: 'Naturist', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } },
sailing: { name: 'Sailing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } },
urban: { name: 'Urban', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false } },
beach: { name: 'Beach', cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
events: { name: 'Events', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
festival: { name: 'Festival', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } },
wintersports: { name: 'Winter Sports', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
naturist: { name: 'Naturist', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } },
sailing: { name: 'Sailing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
};
// Current active filter profile
@@ -200,7 +200,7 @@ export function UTCIForecast() {
// 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({ ...FILTER_PROFILES.basic.cols });
const [visibleCols, setVisibleCols] = useState({ ...FILTER_PROFILES.basic.cols, vis: false, aqi: false, pollen: false });
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
// Skin type for the sunburn-time column. Fitzpatrick II is typical UK fair.
@@ -221,6 +221,26 @@ export function UTCIForecast() {
const [buildingType, setBuildingType] = useState('brick');
const [indoorManaged, setIndoorManaged] = useState(false);
const [indoorMode, setIndoorMode] = useState('off');
// Pollen type for the pollen column. Persisted in localStorage.
const [pollenType, setPollenType] = useState(() => {
try { return localStorage.getItem('sunscope_pollen_type') || 'all_pollen'; } catch (e) { return 'all_pollen'; }
});
const setPollenTypeAndSave = (v) => {
try { localStorage.setItem('sunscope_pollen_type', v); } catch (e) { /* ignore */ }
setPollenType(v);
};
// Pollen type options shown in the pulldown selector.
const POLLEN_TYPES = {
all_pollen: { name: 'All pollen', unit: 'grains/m³' },
grass_pollen: { name: 'Grass pollen', unit: 'grains/m³' },
birch_pollen: { name: 'Birch pollen', unit: 'grains/m³' },
alder_pollen: { name: 'Alder pollen', unit: 'grains/m³' },
mugwort_pollen: { name: 'Mugwort pollen', unit: 'grains/m³' },
olive_pollen: { name: 'Olive pollen', unit: 'grains/m³' },
ragweed_pollen: { name: 'Ragweed pollen', unit: 'grains/m³' },
};
const searchTimeout = useRef(null);
const activateProfile = (key) => {
@@ -385,6 +405,9 @@ export function UTCIForecast() {
vehicleT: { title: 'Vehicle Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces with retained warmth, lower glass gain, and slower heat response. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' },
indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.' },
managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' },
vis: { title: 'Visibility', desc: 'Horizontal visibility in kilometres, sourced from the CAMS air quality model. Values below 1 km indicate fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, and photography.' },
aqi: { title: 'Air Quality Index', desc: 'European Air Quality Index (0100+), combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide. 020 = Good; 2040 = Fair; 4060 = Moderate; 6080 = Poor; 80100 = Very Poor; 100+ = Extremely Poor. Sourced from Copernicus CAMS.' },
pollen: { title: 'Pollen', desc: 'Pollen concentration in grains per cubic metre for the selected pollen type, sourced from the Copernicus CAMS pollen forecast. Low = <10; Moderate = 1050; High = 50200; Very High = 200+. Values vary by species and season.' },
};
const calcPopupPos = (thEl) => {
@@ -689,7 +712,7 @@ export function UTCIForecast() {
`wind_speed_10m,wind_direction_10m,wind_gusts_10m,` +
`direct_radiation,diffuse_radiation,shortwave_radiation,` +
`cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` +
`uv_index,precipitation,snowfall,` +
`uv_index,precipitation,snowfall,visibility,` +
`soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
const r = await fetch(url);
@@ -699,8 +722,45 @@ export function UTCIForecast() {
finally { setLoading(false); }
}
load();
loadAirQuality(location);
}, [location]);
// ─── AIR QUALITY FETCH (with 6-hour localStorage cache) ──────────────
// Fetches visibility, European AQI, and pollen from the Open-Meteo
// Air Quality API. Cached per location for 6 hours — pollen and AQI
// data updates at most a couple of times per day so there's no need
// to hit the API every 5 minutes with the weather refresh.
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
async function loadAirQuality(loc) {
const cacheKey = `sunscope_aq_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`;
try {
const cached = localStorage.getItem(cacheKey);
if (cached) {
const { ts, data } = JSON.parse(cached);
if (Date.now() - ts < SIX_HOURS_MS) {
setAirQuality(data);
return;
}
}
} catch (e) { /* ignore bad cache */ }
try {
const url =
`https://air-quality-api.open-meteo.com/v1/air-quality` +
`?latitude=${loc.lat}&longitude=${loc.lon}` +
`&hourly=european_aqi,` +
`grass_pollen,birch_pollen,alder_pollen,` +
`mugwort_pollen,olive_pollen,ragweed_pollen` +
`&timezone=auto&forecast_days=5`;
const r = await fetch(url);
if (!r.ok) return; // silently fail — these columns just show '—'
const data = await r.json();
setAirQuality(data);
try {
localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data }));
} catch (e) { /* ignore storage errors */ }
} catch (_) { /* silently ignore */ }
}
// ─── AUTO-REFRESH — tick every 5 minutes ─────────────────────────────
// Updates `now` so the scope always shows the current hour's data.
// Also refetches the forecast so fresh API data comes in automatically.
@@ -720,12 +780,22 @@ export function UTCIForecast() {
`wind_speed_10m,wind_direction_10m,wind_gusts_10m,` +
`direct_radiation,diffuse_radiation,shortwave_radiation,` +
`cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` +
`uv_index,precipitation,snowfall,` +
`uv_index,precipitation,snowfall,visibility,` +
`soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
const r = await fetch(url);
if (r.ok) setForecast(await r.json());
} catch (_) { /* silently ignore refresh errors */ }
// Air quality: only refetch if cache has expired (6-hour TTL)
const cacheKey = `sunscope_aq_${location.lat.toFixed(4)}_${location.lon.toFixed(4)}`;
try {
const cached = localStorage.getItem(cacheKey);
if (cached) {
const { ts } = JSON.parse(cached);
if (Date.now() - ts < SIX_HOURS_MS) return; // still fresh
}
} catch (e) { /* ignore */ }
loadAirQuality(location);
}
refresh();
}, FIVE_MIN);
@@ -736,19 +806,27 @@ export function UTCIForecast() {
// 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.
//
// Open-Meteo with timezone=auto returns local wall-clock strings like
// "2026-05-13T14:00" — no Z, no offset suffix. We need two things:
// 1. The wall-clock hour for display & day grouping (just slice the string)
// 2. The true UTC instant for solarElevationDeg (which uses .getUTC* internally)
// Strategy: treat the ISO string as UTC (append Z), which gives a Date whose
// UTC hours equal the local wall-clock hour. Then ADD the utc_offset_seconds
// to shift it to the real UTC instant. e.g. Brisbane UTC+10: "14:00" local
// → parse as UTC 14:00 → add 10h → UTC 00:00 next day? No — subtract.
// Brisbane local 14:00 = UTC 04:00, offset = +10h, so UTC = local - offset.
// Date.parse("2026-05-13T14:00Z") = ms for UTC 14:00
// Subtract offset (+10h = 36000000ms) → UTC 04:00. Correct.
// "2026-05-13T14:00" — no Z suffix. We use two forms:
// • String slices (iso.slice(...)) for display & day grouping
// • A true UTC Date for solarElevationDeg (see per-row comment below)
const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000;
// Build a fast lookup map from the air quality hourly data: ISO string → index.
// Normalise to "YYYY-MM-DDTHH" (13 chars) so forecast timestamps like
// "2026-05-17T14:00" match AQ timestamps like "2026-05-17T14".
const aqTimeMap = {};
if (airQuality?.hourly?.time) {
airQuality.hourly.time.forEach((t, i) => { aqTimeMap[t.slice(0, 13)] = i; });
}
const getAq = (field, iso) => {
if (!airQuality?.hourly?.[field]) return null;
const i = aqTimeMap[iso.slice(0, 13)];
if (i === undefined) return null;
return airQuality.hourly[field][i] ?? null;
};
const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => {
const h = forecast.hourly;
const Ta = h.temperature_2m[i];
@@ -776,19 +854,28 @@ export function UTCIForecast() {
// For solarElevationDeg (which uses .getUTC* internally) we need the
// true UTC instant: treat the local time as UTC then subtract the offset.
// e.g. Brisbane UTC+10: local 14:00 → parse as UTC 14:00 → subtract 10h → UTC 04:00 ✓
const dtUTC = new Date(Date.parse(iso + 'Z') - utcOffsetMs);
// dt kept for SkyScope / backward compat — same as dtUTC.
const dt = dtUTC;
const elev = solarElevationDeg(location.lat, location.lon, dtUTC);
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent);
const eh = vaporPressureHpa(Ta, RH);
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
const utci = utciApprox(Ta, Tmrt, va, eh);
// True UTC instant: treat the local wall-clock string as UTC, then
// subtract the offset. e.g. Brisbane UTC+10, local 14:00 → UTC 04:00.
const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs);
const elev = solarElevationDeg(location.lat, location.lon, dt);
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent);
const eh = vaporPressureHpa(Ta, RH);
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
const utci = utciApprox(Ta, Tmrt, va, eh);
const utciAdj = utci + precipPenalty(precip, snow, va);
// Derived
const compass = windCompass8(wd);
const { uvA, uvB } = uvSplit(uv, elev);
const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh);
// Visibility from the main forecast API (metres → km).
const visKm = (() => { const v = h.visibility ? h.visibility[i] : null; return v != null ? v / 1000 : null; })();
const aqi = getAq('european_aqi', iso);
const grassPollen = getAq('grass_pollen', iso);
const birchPollen = getAq('birch_pollen', iso);
const alderPollen = getAq('alder_pollen', iso);
const mugwortPollen= getAq('mugwort_pollen', iso);
const olivePollen = getAq('olive_pollen', iso);
const ragweedPollen= getAq('ragweed_pollen', iso);
return {
iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob,
cc, ccLow, ccMid, ccHigh, cloudCat,
@@ -796,6 +883,8 @@ export function UTCIForecast() {
precip, snow,
soilT0, soilT6, soilM, concreteT, vehicleT,
elev, Tmrt, utci, utciAdj, eh, compass,
visKm, aqi,
grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen,
};
}) : [];
@@ -921,7 +1010,6 @@ export function UTCIForecast() {
return html`
<div class="event-banner"
style=${{ background: ev.color, color: ev.textColor }}
onMouseEnter=${() => {}}
>
<span class="event-banner-emoji">${ev.emoji}</span>
<div class="event-banner-body">
@@ -1061,6 +1149,26 @@ export function UTCIForecast() {
const utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v));
const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null;
const dayLo = utcipVals.length ? Math.round(Math.min(...utcipVals)) : null;
// ── Day-tab weather icon ───────────────────────────────────
// Use daytime rows (elev > 0) where available, else all rows.
// Pick the modal cloud category and sum precip/snow to decide
// whether to show a PrecipIcon or a CloudIcon.
const dayRows = d.rows.filter(r => r.elev > 0);
const repRows = dayRows.length > 0 ? dayRows : d.rows;
const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0);
const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0);
// Modal cloud category (most frequent among daytime hours)
const catCounts = {};
repRows.forEach(r => { if (r.cloudCat) catCounts[r.cloudCat] = (catCounts[r.cloudCat] || 0) + 1; });
const modalCloudCat = Object.keys(catCounts).sort((a, b) => catCounts[b] - catCounts[a])[0] || 'clear';
// Representative solar elevation: midday row or median of daytime rows
const midRow = repRows[Math.floor(repRows.length / 2)];
const repElev = midRow ? midRow.elev : 45;
const repDt = midRow ? midRow.dt : dDate;
// Show PrecipIcon when total daytime precip ≥ 0.3 mm or snow ≥ 0.1 cm
const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1;
return html`
<button
key=${d.key}
@@ -1095,13 +1203,18 @@ export function UTCIForecast() {
<span class="utci-day-date" style=${{ color: '#5a3f24' }}>
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
</span>
<span style=${{ display: 'block', margin: '3px auto 0', lineHeight: 1 }}>
${showPrecip
? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${22} />`
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${22} />`}
</span>
${dayHi !== null && html`
<span style=${{
display: 'block',
fontFamily: 'Fraunces, serif',
fontStyle: 'italic',
fontSize: '11px',
marginTop: '3px',
marginTop: '2px',
letterSpacing: 0,
textTransform: 'none',
}}>
@@ -1209,8 +1322,8 @@ export function UTCIForecast() {
}}>
<a
href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00"
target="_blank"
rel="noopener noreferrer"
target="_blank"
rel="noopener noreferrer"
style=${{
display: 'inline-block',
padding: '10px 18px',
@@ -1340,7 +1453,7 @@ export function UTCIForecast() {
}}
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
})}
<span class="profile-divider" aria-hidden="true"></span>
<span class="profile-divider" aria-hidden="true"></span>
<${CustomSelect}
key="activities"
value=${activityValue}
@@ -1439,6 +1552,26 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['wind']) && html`<button class=${`col-toggle${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dir']) && html`<button class=${`col-toggle${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['cloud']) && html`<button class=${`col-toggle${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
<button class=${`col-toggle${visibleCols.vis ? ' on' : ''}`} onClick=${() => toggleCol('vis')}>Visibility</button>
<button class=${`col-toggle${visibleCols.aqi ? ' on' : ''}`} onClick=${() => toggleCol('aqi')}>Air Quality</button>
<${CustomSelect}
value=${pollenType}
isOn=${visibleCols.pollen}
hideLabel="Pollen"
hidingLabel="Hide Pollen"
options=${Object.entries(POLLEN_TYPES).flatMap(([k, v], i) => [
{ value: k, label: v.name },
...(i === 0 ? [{ value: '_div', divider: true }] : []),
])}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, pollen: false }));
} else {
setPollenTypeAndSave(v);
setVisibleCols(prev => ({ ...prev, pollen: true }));
}
}}
/>
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['sun']) && html`<button class=${`col-toggle${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['direct']) && html`<button class=${`col-toggle${visibleCols.direct ? ' on' : ''}`} onClick=${() => toggleCol('direct')}>Direct</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['diffuse']) && html`<button class=${`col-toggle${visibleCols.diffuse ? ' on' : ''}`} onClick=${() => toggleCol('diffuse')}>Diffuse</button>`}
@@ -1565,6 +1698,9 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
${visibleCols.wind && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">m/s (gust)</span></th>`}
${visibleCols.dir && html`<th class="utci-dir-cell col-info-th" scope="col" onClick=${(e) => handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir <span class="col-unit">-</span></th>`}
${visibleCols.cloud && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud <span class="col-unit">%</span></th>`}
${visibleCols.vis && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Visibility <span class="col-unit">km</span></th>`}
${visibleCols.aqi && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('aqi', e)} onMouseEnter=${(e) => handleThEnter('aqi', e)} onMouseLeave=${handleThLeave}>AQI <span class="col-unit">EU idx</span></th>`}
${visibleCols.pollen && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('pollen', e)} onMouseEnter=${(e) => handleThEnter('pollen', e)} onMouseLeave=${handleThLeave}>${pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen','') ?? 'Pollen'} <span class="col-unit">grains/m³</span></th>`}
${visibleCols.sun && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun <span class="col-unit">elev°</span></th>`}
${visibleCols.direct && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('direct', e)} onMouseEnter=${(e) => handleThEnter('direct', e)} onMouseLeave=${handleThLeave}>Direct <span class="col-unit">W/m²</span></th>`}
${visibleCols.diffuse && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('diffuse', e)} onMouseEnter=${(e) => handleThEnter('diffuse', e)} onMouseLeave=${handleThLeave}>Diffuse <span class="col-unit">W/m²</span></th>`}
@@ -1700,6 +1836,61 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
<span>${Math.round(r.cc)}</span>
</span>
</td>`}
${visibleCols.vis && (() => {
const v = r.visKm;
const bg = v == null ? 'transparent'
: v < 1 ? 'rgba(180,80,80,0.18)'
: v < 4 ? 'rgba(210,140,50,0.15)'
: v < 10 ? 'rgba(200,190,80,0.12)'
: 'transparent';
const color = v != null && v < 4 ? '#8a3a1a' : '#4a3218';
return html`<td style=${{ background: bg, color }}>
${v != null ? (v < 10 ? v.toFixed(1) : Math.round(v)) : ''}
</td>`;
})()}
${visibleCols.aqi && (() => {
const v = r.aqi;
const bg = v == null ? 'transparent'
: v < 20 ? 'rgba(80,180,100,0.15)'
: v < 40 ? 'rgba(140,200,100,0.13)'
: v < 60 ? 'rgba(220,200,60,0.15)'
: v < 80 ? 'rgba(220,130,50,0.18)'
: v < 100 ? 'rgba(200,70,50,0.18)'
: 'rgba(160,30,100,0.20)';
const color = v != null && v >= 60 ? '#7a2010' : v != null && v >= 40 ? '#7a4a10' : '#2a4a20';
const label = v == null ? ''
: v < 20 ? `${v} Good`
: v < 40 ? `${v} Fair`
: v < 60 ? `${v} Mod`
: v < 80 ? `${v} Poor`
: v < 100 ? `${v} V.Poor`
: `${v} Hazard`;
return html`<td style=${{ background: bg, color, fontWeight: v != null && v >= 60 ? 600 : 400 }}>${label}</td>`;
})()}
${visibleCols.pollen && (() => {
const pollenMap = {
all_pollen: [r.grassPollen, r.birchPollen, r.alderPollen, r.mugwortPollen, r.olivePollen, r.ragweedPollen].reduce((s, x) => x != null ? s + x : s, null),
grass_pollen: r.grassPollen,
birch_pollen: r.birchPollen,
alder_pollen: r.alderPollen,
mugwort_pollen: r.mugwortPollen,
olive_pollen: r.olivePollen,
ragweed_pollen: r.ragweedPollen,
};
const v = pollenMap[pollenType] ?? null;
const bg = v == null ? 'transparent'
: v < 10 ? 'transparent'
: v < 50 ? 'rgba(180,200,80,0.13)'
: v < 200 ? 'rgba(210,150,50,0.16)'
: 'rgba(200,70,50,0.18)';
const color = v != null && v >= 200 ? '#8a2010' : v != null && v >= 50 ? '#7a4a10' : '#4a3218';
const label = v == null ? ''
: v < 10 ? `${Math.round(v)} Low`
: v < 50 ? `${Math.round(v)} Mod`
: v < 200 ? `${Math.round(v)} High`
: `${Math.round(v)} V.High`;
return html`<td style=${{ background: bg, color, fontWeight: v != null && v >= 50 ? 600 : 400 }}>${label}</td>`;
})()}
${visibleCols.sun && html`<td style=${{ background: scaleBg(r.elev > 0 ? r.elev : null, 0, 70, '225,160,45') }}>${r.elev > 0 ? r.elev.toFixed(1) : ''}</td>`}
${visibleCols.direct && html`<td style=${{ background: scaleBg(r.dir, 0, 850, '230,155,35') }}>${Math.round(r.dir)}</td>`}
${visibleCols.diffuse && html`<td style=${{ background: scaleBg(r.dif, 0, 450, '230,190,70') }}>${Math.round(r.dif)}</td>`}
+41 -22
View File
@@ -1,16 +1,18 @@
// ════════════════════════════════════════════════════════════════════════
// components.js — Preact UI components (SVG widgets).
//
// Exports:
// SkyScope({ elev, dt, size }) little porthole circle per hour
// WindVane({ bearing, size }) compass arrow
// CloudIcon({ category, size, elev, dt }) cloud/sun/moon icon
// ScopeReticle({ value, cat, loading, elev, dt }) big UTCI dial
// PrecipIcon({ precip, snow, size }) rain/snow icon for precip cell
// components.js — Preact UI components (SVG widgets and controls).
//
// All components use the html`` tagged template from htm + Preact.
// They depend on utils.js (skyFillForElev, grassFillForElev,
// moonPhaseFraction) but have no other external state.
// No external state; they depend only on utils.js and events.js.
//
// Exports (in order of appearance):
// CustomSelect({ value, options, onChange, ... }) styled dropdown pill
// VentPill({ checked, onChange, label, title }) checkbox fused to pill
// SkyScope({ elev, dt, glob, size }) small porthole per hour
// WindVane({ bearing, size }) compass-arrow widget
// CloudIcon({ category, size, elev, dt }) cloud/sun glyph
// ScopeReticle({ value, cat, loading, elev, dt, glob, activeEvent })
// large UTCI dial
// PrecipIcon({ precip, snow, size }) rain/snow icon
// ════════════════════════════════════════════════════════════════════════
import { h, Fragment } from '../vendor/preact.js';
@@ -91,8 +93,9 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
>${hidingLabel || ('Hide ' + hideLabel)}</span>
<div class="cs-divider"></div>
`}
${options.map(opt => html`
<span
${options.map(opt => opt.divider
? html`<div key=${opt.value} class="cs-divider"></div>`
: html`<span
key=${opt.value}
class=${`cs-option${opt.value === value && isOn ? ' selected' : ''}${opt.disabled ? ' disabled' : ''}`}
role="option"
@@ -101,8 +104,8 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
onChange(opt.value);
setOpen(false);
}}
>${opt.label}</span>
`)}
>${opt.label}</span>`
)}
</div>
`}
</span>`;
@@ -342,23 +345,39 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
${line(size * 0.30, y0 + size * 0.18, size * 0.92, y0 + size * 0.18, ink, sw * 0.82, 0.72)}
</g>`;
// Sun shape — circle + 8 rays, centred at (cx, cy)
const sun = (cx, cy, cr) => {
const rays = Array.from({ length: 8 }, (_, k) => {
const angle = (k * Math.PI) / 4;
const r1 = cr * 1.55;
const r2 = cr * 2.1;
return html`<line key=${k}
x1=${cx + Math.cos(angle) * r1} y1=${cy + Math.sin(angle) * r1}
x2=${cx + Math.cos(angle) * r2} y2=${cy + Math.sin(angle) * r2}
stroke=${brass} stroke-width=${sw * 0.85} stroke-linecap="round" opacity="0.9" />`;
});
return html`<g>
${rays}
<circle cx=${cx} cy=${cy} r=${cr} fill="none" stroke=${brass} stroke-width=${sw} opacity="0.95" />
</g>`;
};
return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
${category === 'clear' && html`
${line(size * 0.26, size * 0.50, size * 0.74, size * 0.50, brass, sw, 0.9)}
${line(size * 0.38, size * 0.64, size * 0.62, size * 0.64, muted, sw * 0.75, 0.7)}`}
${sun(size * 0.50, size * 0.38, size * 0.16)}`}
${category === 'wispy' && html`
${mist(size * 0.38, brass)}
${line(size * 0.18, size * 0.78, size * 0.60, size * 0.78, muted, sw * 0.7, 0.6)}`}
${mist(size * 0.18, brass)}
${line(size * 0.18, size * 0.56, size * 0.60, size * 0.56, muted, sw * 0.7, 0.6)}`}
${category === 'scattered' && html`
<path d=${cloudPath(0, 0, 0.82)} fill="none" stroke=${ink}
<path d=${cloudPath(0, -size * 0.2425, 0.82)} fill="none" stroke=${ink}
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />
${line(size * 0.20, size * 0.78, size * 0.48, size * 0.78, brass, sw * 0.75, 0.78)}`}
${line(size * 0.20, size * 0.58, size * 0.48, size * 0.58, brass, sw * 0.75, 0.78)}`}
${category === 'overcast' && html`
<path d=${cloudPath(-size * 0.02, -size * 0.10, 0.70)} fill="none" stroke=${muted}
<path d=${cloudPath(-size * 0.02, -size * 0.2275, 0.70)} fill="none" stroke=${muted}
stroke-width=${sw * 0.9} stroke-linecap=${cap} stroke-linejoin=${join} opacity="0.82" />
<path d=${cloudPath(0, size * 0.14, 0.88)} fill="none" stroke=${ink}
<path d=${cloudPath(0, -size * 0.25, 0.88)} fill="none" stroke=${ink}
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />`}
</svg>`;
}
+55 -26
View File
@@ -193,7 +193,7 @@ const COSMIC_CALENDAR = [
// ── 2027 METEOR SHOWERS ─────────────────────────────────────────────
{
id: 'quadrantids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F),
emoji: '☄️',
title: 'Quadrantid Meteor Shower',
message: 'The Quadrantid meteor shower is active — up to 120 meteors/hour at peak. Best in the hours before dawn on 4 Jan from a dark site.',
color: '#2a1a5a', textColor: '#e8d8ff',
@@ -202,7 +202,7 @@ const COSMIC_CALENDAR = [
},
{
id: 'lyrids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F),
emoji: '☄️',
title: 'Lyrid Meteor Shower',
message: 'The Lyrid meteor shower peaks — up to 20 meteors/hour after midnight. Note: bright waning gibbous moon may reduce visibility this year.',
color: '#2a1a5a', textColor: '#e8d8ff',
@@ -211,7 +211,7 @@ const COSMIC_CALENDAR = [
},
{
id: 'eta-aquariids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F),
emoji: '☄️',
title: 'Eta Aquariid Meteor Shower',
message: 'The Eta Aquariids peak — fragments of Halley\'s Comet, up to 50/hour before dawn. New moon on 6 May means excellent dark skies this year.',
color: '#2a1a5a', textColor: '#e8d8ff',
@@ -220,7 +220,7 @@ const COSMIC_CALENDAR = [
},
{
id: 'perseids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F),
emoji: '☄️',
title: 'Perseid Meteor Shower',
message: 'The Perseids are active — one of the best showers of the year, up to 100 meteors/hour at peak around 13 Aug.',
color: '#3a1a00', textColor: '#ffe8c0',
@@ -229,7 +229,7 @@ const COSMIC_CALENDAR = [
},
{
id: 'orionids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F),
emoji: '☄️',
title: 'Orionid Meteor Shower',
message: 'The Orionid shower peaks — fast, bright meteors from Halley\'s Comet debris. Up to 25/hour.',
color: '#2a1a5a', textColor: '#e8d8ff',
@@ -238,7 +238,7 @@ const COSMIC_CALENDAR = [
},
{
id: 'leonids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F),
emoji: '☄️',
title: 'Leonid Meteor Shower',
message: 'The Leonid shower peaks — fast meteors from comet Tempel-Tuttle, up to 15/hour.',
color: '#2a1a5a', textColor: '#e8d8ff',
@@ -247,7 +247,7 @@ const COSMIC_CALENDAR = [
},
{
id: 'geminids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F),
emoji: '☄️',
title: 'Geminid Meteor Shower',
message: 'The Geminids peak — the finest shower of the year, up to 150 meteors/hour, visible even before midnight.',
color: '#3a1a00', textColor: '#ffe8c0',
@@ -256,7 +256,7 @@ const COSMIC_CALENDAR = [
},
{
id: 'ursids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F),
emoji: '☄️',
title: 'Ursid Meteor Shower',
message: 'The Ursid shower peaks near the winter solstice — circumpolar, best from northern latitudes.',
color: '#2a1a5a', textColor: '#e8d8ff',
@@ -267,7 +267,7 @@ const COSMIC_CALENDAR = [
// ── 2027 ECLIPSES ───────────────────────────────────────────────────
{
id: 'annular-solar-eclipse-2027-feb',
emoji: String.fromCodePoint(0x1F311),
emoji: '🌑',
title: 'Annular Solar Eclipse',
message: 'An annular solar eclipse creates a "ring of fire" effect — visible across parts of South America, Africa and the Indian Ocean.',
color: '#1a0a2a', textColor: '#d8c8f8',
@@ -276,7 +276,7 @@ const COSMIC_CALENDAR = [
},
{
id: 'total-solar-eclipse-2027-aug',
emoji: String.fromCodePoint(0x1F311),
emoji: '🌑',
title: 'Total Solar Eclipse',
message: 'A spectacular total solar eclipse — the path of totality crosses Morocco, Spain, Algeria, Libya, Egypt and Saudi Arabia. One of the longest totalities of the century.',
color: '#1a0a2a', textColor: '#d8c8f8',
@@ -287,7 +287,7 @@ const COSMIC_CALENDAR = [
// ── 2027 PLANETARY EVENTS ───────────────────────────────────────────
{
id: 'mars-opposition-2027',
emoji: String.fromCodePoint(0x1F534),
emoji: '🔴',
title: 'Mars at Opposition',
message: 'Mars is at its closest and brightest — a vivid red beacon visible all night long. Good binoculars will reveal its surface colour.',
color: '#2a1520', textColor: '#ffc8d8',
@@ -296,21 +296,23 @@ const COSMIC_CALENDAR = [
},
{
id: 'venus-jupiter-conjunction-2027',
emoji: String.fromCodePoint(0x1FA90),
emoji: '🪐',
title: 'Venus & Jupiter Conjunction',
message: 'Venus and Jupiter appear strikingly close together in the evening sky — a dazzling naked-eye pairing of the two brightest planets.',
color: '#1a2a3a', textColor: '#c8e8ff',
type: 'cosmic', nightOnly: true,
start: '2027-08-23', end: '2027-08-27', peak: '2027-08-25',
},
];
// ─── WEATHER-DERIVED EVENTS ───────────────────────────────────────────────
// These are computed from the actual forecast data.
// Each checker receives (rows, location) and returns an event object or null.
// rows = today's hourlyRows array. location = { lat, lon, name, country }.
// Computed in real-time from the forecast data.
// Each checker function receives (rows [, location]) and returns an event
// object or null. rows = today's hourlyRows array.
// location = { lat, lon, name, country }.
//
// To add a new weather event: write a checkXxx(rows, location) function
// below and add it to the checks[] array inside getActiveEvents().
function checkStargazing(rows) {
// Great stargazing: mostly clear night hours with low cloud
@@ -748,6 +750,33 @@ export function getCellTagEvents(events, row) {
});
}
// ─── DYNAMIC MESSAGE GENERATOR ───────────────────────────────────────────
// Rewrites a cosmic event's message based on where today sits vs. the peak.
// Before peak : "is active and building — peak on <date>. <detail>"
// On peak ±1d : "peaks tonight — <detail>"
// After peak : "is past its peak (<date>) but still possibly visible — <detail>"
// Single-day events (start === end) keep their static message unchanged.
function dynamicCosmicMessage(ev, dateStr) {
if (!ev.peak || ev.start === ev.end) return ev.message;
var today = new Date(dateStr + 'T00:00Z');
var peak = new Date(ev.peak + 'T00:00Z');
var diffDays = Math.round((today - peak) / 86400000);
var peakFmt = peak.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' });
var base = ev.message
.replace(/^.*?(?:peaks tonight|peak tonight|is active tonight|is active|peaks)\s*—\s*/i, '')
.trim();
var baseCapd = base.charAt(0).toUpperCase() + base.slice(1);
if (diffDays < -1) {
return ev.title + ' is active and building — peak on ' + peakFmt + '. ' + baseCapd;
} else if (diffDays <= 1) {
return ev.title + ' peaks tonight — ' + base;
} else {
return ev.title + ' is past its peak (' + peakFmt + ') but still possibly visible — ' + base;
}
}
// ─── MAIN EXPORT ─────────────────────────────────────────────────────────
// Returns ALL active events for the given rows/date as an array.
// Empty array = nothing active.
@@ -765,7 +794,9 @@ export function getActiveEvents(rows, location) {
const dateStr = (rows && rows.length > 0)
? rows[0].iso.slice(0, 10)
: new Date().toISOString().slice(0, 10);
const cosmicHits = COSMIC_CALENDAR.filter(ev => dateStr >= ev.start && dateStr <= ev.end);
const cosmicHits = COSMIC_CALENDAR
.filter(ev => dateStr >= ev.start && dateStr <= ev.end)
.map(ev => Object.assign({}, ev, { message: dynamicCosmicMessage(ev, dateStr) }));
// 3. Weather-derived events (all that match, not just first)
const weatherEvents = [];
@@ -802,16 +833,14 @@ export function getLensEvent(events) {
}
// ─── LENS OVERLAY RENDERER ───────────────────────────────────────────────
// Returns SVG group content for the event overlay in the big ScopeReticle.
// Rendered INSIDE the lens clip and BELOW the glass shine/shade layers.
// cx, cy = lens centre; lensR = lens radius.
// Returns an SVG string for the event overlay inside the big ScopeReticle.
// Rendered INSIDE the lens clip path, BELOW the glass shine/shade layers.
// cx, cy = lens centre coords; lensR = lens radius.
export function getLensOverlaySVG(event, cx, cy, lensR) {
if (!event) return null;
const id = event.id;
if (event.emoji === String.fromCodePoint(0x2604, 0xFE0F)) {
if (event.emoji === '☄️') {
return [
'<g opacity="0.55" clip-path="url(#scope-lens-clip)">',
'<line x1="' + (cx-60) + '" y1="' + (cy-50) + '" x2="' + (cx-20) + '" y2="' + (cy-20) + '" stroke="#ffe8a0" stroke-width="1.5" stroke-linecap="round" opacity="0.9"/>',
@@ -845,13 +874,13 @@ export function getLensOverlaySVG(event, cx, cy, lensR) {
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="16" fill="none" stroke="rgba(220,60,20,0.5)" stroke-width="1.5" /></g>';
}
if (event.emoji === String.fromCodePoint(0x1FA90)) {
if (event.emoji === '🪐' && event.id.includes('conjunction')) {
return '<g clip-path="url(#scope-lens-clip)" opacity="0.65">'
+ '<circle cx="' + (cx+20) + '" cy="' + (cy-35) + '" r="10" fill="rgba(210,185,130,0.7)" />'
+ '<ellipse cx="' + (cx+20) + '" cy="' + (cy-35) + '" rx="18" ry="4.5" fill="none" stroke="rgba(200,170,100,0.65)" stroke-width="2.5" /></g>';
}
if (event.emoji === String.fromCodePoint(0x1F534)) {
if (event.emoji === '🔴') {
return '<g clip-path="url(#scope-lens-clip)" opacity="0.6">'
+ '<circle cx="' + (cx+30) + '" cy="' + (cy-30) + '" r="7" fill="rgba(200,80,40,0.75)" />'
+ '<circle cx="' + (cx-20) + '" cy="' + (cy-45) + '" r="5" fill="rgba(220,160,80,0.7)" /></g>';
+40 -31
View File
@@ -1,18 +1,19 @@
// ════════════════════════════════════════════════════════════════════════
// physics.js — Physical constants and meteorological calculations.
//
// Exports:
// SIGMA, EPSILON_P, A_K, ALBEDO_GRASS (radiation constants)
// vaporPressureHpa(Ta, RH) Magnus formula → hPa
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position
// calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) Mean radiant temp
// utciApprox(Ta, Tmrt, va10, ehPa) Bröde et al. 2012 polynomial
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated)
// calcIndoorTempPass(TaArr, globArr, elevArr, buildingType)
// calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType)
// All numbers are peer-reviewed constants or coefficients. Nothing in
// here should need editing unless the underlying science changes.
//
// Nothing in here should need editing unless the underlying science
// changes. All numbers are peer-reviewed constants or coefficients.
// Exports (in order of appearance):
// SIGMA, EPSILON_P, A_K, ALBEDO_GRASS, ALBEDO_CONCRETE (radiation constants)
// calcConcreteTemp(Ta, globalRad, windSpeed) urban surface temp
// calcIndoorTempPass(TaArr, globArr, elevArr, buildingType) passive indoor temp
// calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType) managed indoor temp
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated)
// vaporPressureHpa(Ta, RH) Magnus formula → hPa
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position (°)
// calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) Mean radiant temperature
// utciApprox(Ta, Tmrt, va10, ehPa) Bröde et al. 2012 UTCI polynomial
// ════════════════════════════════════════════════════════════════════════
import { VEHICLE_TYPES, BUILDING_TYPES } from './utils.js';
@@ -104,23 +105,6 @@ export function calcConcreteTemp(Ta, globalRad, windSpeed) {
// > 32 °C — hot; risk for elderly and vulnerable occupants
// ═══════════════════════════════════════════════════════════════════
// Single-hour instantaneous heat load (W/m² effective, indoor side).
// Used internally by calcIndoorTempPass — not exported.
function _houseHeatLoad(Ta, globalRad, solElev) {
// Wall + roof conduction: U-value ~0.35 W/m²K × effective envelope area ratio
// We express as a gain-per-degree-delta — applied against Ti later in the pass.
// (See calcIndoorTempPass for how this feeds the lag model.)
// Window solar gain: glazing ratio 0.16, g-value 0.63 (standard double glazing),
// averaged across orientations (0.5 factor — not all windows face the sun).
const glazingRatio = 0.16;
const gValue = 0.63;
const orientFactor = 0.50;
const solarGain = globalRad * glazingRatio * gValue * orientFactor;
return { conductionDelta: Ta, solarGain };
}
// Two-pass function: call with the full arrays of hourly Ta and globalRad.
// Returns an array of indoor temperatures, one per hour.
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
@@ -332,13 +316,24 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'c
// for road-trip planning, dog-in-car safety at a rest stop vs. motorway, etc.
// ─────────────────────────────────────────────────────────────────────────────
// Vapour pressure (Magnus → hPa)
// ═══════════════════════════════════════════════════════════════════
// VAPOUR PRESSURE — Magnus formula.
// ───────────────────────────────────────────────────────────────────
// Converts air temperature (°C) and relative humidity (%) to vapour
// pressure in hPa. Used as the humidity input to utciApprox().
// ═══════════════════════════════════════════════════════════════════
export 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)
// ═══════════════════════════════════════════════════════════════════
// SOLAR ELEVATION — NOAA simplified algorithm (degrees above horizon).
// ───────────────────────────────────────────────────────────────────
// Accurate to within ~0.01° for most practical purposes. Returns a
// negative value when the sun is below the horizon (civil twilight
// starts at 6°, nautical at 12°, astronomical at 18°).
// ═══════════════════════════════════════════════════════════════════
export function solarElevationDeg(lat, lon, dateUTC) {
const start = Date.UTC(dateUTC.getUTCFullYear(), 0, 0);
const diff = dateUTC - start;
@@ -374,7 +369,21 @@ export function solarElevationDeg(lat, lon, dateUTC) {
return (Math.PI / 2 - zenith) * (180 / Math.PI);
}
// Mean radiant temperature
// ═══════════════════════════════════════════════════════════════════
// MEAN RADIANT TEMPERATURE (Tmrt)
// ───────────────────────────────────────────────────────────────────
// Tmrt is the uniform temperature of an imaginary enclosure that would
// cause the same net radiation exchange as the actual environment.
// It accounts for:
// • Direct solar beam (DNI), scaled by the projected-area factor fp
// • Diffuse sky radiation (scattered and cloud-reflected)
// • Ground-reflected shortwave (albedo × global radiation)
// • Longwave thermal emission from surrounding surfaces (≈ blackbody at Ta)
//
// The fabric index 0.308 (fp formula from ISO 7933) projects the sun
// onto a standing person's silhouette as a function of solar elevation.
// Output feeds directly into utciApprox() as the Tmrt argument.
// ═══════════════════════════════════════════════════════════════════
export function calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) {
const TaK = Ta + 273.15;
let fp = 0;
+32 -19
View File
@@ -1,22 +1,26 @@
// ════════════════════════════════════════════════════════════════════════
// utils.js — Pure helper functions and lookup tables.
//
// Exports:
// utciCategory(u) UTCI stress band → {label,bg,fg}
// precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor
// windCompass8(deg) bearing → {label, snapped}
// uvSplit(uv, elevDeg) total UV → {uvA, uvB}
// SKIN_TYPES Fitzpatrick skin type table
// sunburnMinutes(uv, skinType) minutes to MED
// burnLabel(mins) formats burn time as "12m"/"1.5h"
// VEHICLE_TYPES vehicle presets for cabin heat model
// BUILDING_TYPES building presets for indoor heat model
// cloudCategory(total,low,mid,high) → 'clear'|'wispy'|'scattered'|'overcast'
// confidenceBand(i) day-tab gradient + label
// moonPhaseFraction(date) 0..1 synodic phase
// moonGlyph(p) phase fraction → emoji
// skyGradientForElev(e, isRising) solar elevation + direction → {top,bot} sky gradient
// grassFillForElev(e) solar elevation → {top,bot} hex
// No side-effects, no DOM access, no API calls. All functions are safe
// to call server-side or in tests.
//
// Exports (in order of appearance):
// utciCategory(u) UTCI stress band → {label,bg,fg}
// precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor (°C penalty)
// windCompass8(deg) bearing → {label, snapped}
// uvSplit(uv, elevDeg) total UV index → {uvA, uvB} estimate
// SKIN_TYPES Fitzpatrick IVI lookup table
// sunburnMinutes(uv, skinType) minutes to MED (sunburn threshold)
// burnLabel(mins) formats burn time as "12m" / "1.5h"
// VEHICLE_TYPES vehicle presets for cabin heat model
// BUILDING_TYPES building presets for indoor heat model
// cloudCategory(total,low,mid,high) → 'clear'|'wispy'|'scattered'|'overcast'
// confidenceBand(i) day-tab gradient colour + label
// moonPhaseFraction(date) 0..1 synodic phase fraction
// moonGlyph(p) phase fraction → moon emoji
// skyGradientForElev(e, isRising) solar elevation → {top,bot} hex pair
// skyFillForElev(e, isRising) convenience single-colour sky fill
// grassFillForElev(e) solar elevation → {top,bot} ground hex
// ════════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════
@@ -269,7 +273,8 @@ export function moonPhaseFraction(date) {
return p;
}
export function moonGlyph(p) {
return ['🌑','🌒','🌓','🌔','🌕','🌖','🌗','🌘'][Math.floor(p*8 + 0.5) % 8];
// +0.5 offset rounds to nearest phase (avoids flickering on exact boundaries)
return ['🌑','🌒','🌓','🌔','🌕','🌖','🌗','🌘'][Math.floor(p * 8 + 0.5) % 8];
}
// ═══════════════════════════════════════════════════════════════════
@@ -339,12 +344,20 @@ export function skyGradientForElev(e, isRising) {
return interpolateSkyKeys(isRising ? SKY_KEYS_RISING : SKY_KEYS_SETTING, e);
}
// Convenience flat colour for contexts needing a single fill.
// ─── skyFillForElev ────────────────────────────────────────────────────────
// Convenience single-colour fill for contexts that don't need a gradient
// (e.g. solid background chips). Returns the horizon (bottom) colour.
export function skyFillForElev(e, isRising = false) {
return skyGradientForElev(e, isRising).bot;
}
// Grass palette — mirrors sky but for the ground strip.
// ═══════════════════════════════════════════════════════════════════
// GRASS FILL — ground-strip colour keyed to solar elevation.
// ───────────────────────────────────────────────────────────────────
// Mirrors the sky palette so the SkyScope disk reads naturally: vivid
// green at noon, amber at golden hour, purple-dark at twilight/night.
// Returns {top, bot} for a subtle two-stop ground gradient.
// ═══════════════════════════════════════════════════════════════════
export function grassFillForElev(e) {
if (e > 60) return { top: '#92cc50', bot: '#5e9630' }; // blazing noon
if (e > 30) return { top: '#86c44a', bot: '#558a2e' }; // bright midday