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
+3
View File
@@ -1,3 +1,6 @@
# Cowork session backups
backup/
# OS / editor cruft
.DS_Store
Thumbs.db
+79 -21
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>About SunScope · The True Temperature</title>
<meta name="description" content="SunScope uses the Universal Thermal Climate Index (UTCI) and an original soak-factor formula to show how the weather will really feel on your body — including the effect of rain, snow, wind and solar radiation." />
<meta name="description" content="SunScope uses the Universal Thermal Climate Index (UTCI) and an original soak-factor formula to show how the weather will really feel on your body — including the effect of rain, snow, wind, solar radiation, air quality, pollen, and visibility." />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://sunscope.net/about.html" />
@@ -36,7 +36,7 @@
"@type": "WebApplication",
"name": "SunScope",
"url": "https://sunscope.net",
"description": "A free hourly UTCI thermal comfort forecast combining air temperature, humidity, wind speed, and solar radiation. Includes vehicle interior temperature, indoor temperature, UV burn time, concrete surface temperature, and soil data.",
"description": "A free hourly UTCI thermal comfort forecast combining air temperature, humidity, wind speed, and solar radiation. Includes vehicle interior temperature, indoor temperature, UV burn time, concrete surface temperature, soil data, air quality index, pollen, and visibility.",
"applicationCategory": "WeatherApplication",
"operatingSystem": "Any",
"offers": {
@@ -187,6 +187,14 @@
"@type": "Answer",
"text": "The free tier shows 3 days of hourly data. The forecast auto-refreshes every 5 minutes while the page is open, so the current hour always reflects the latest available data from Open-Meteo."
}
},
{
"@type": "Question",
"name": "Does SunScope show air quality and pollen forecasts?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. SunScope includes three air environment columns: Visibility (horizontal visibility in km, useful for fog, haze, and driving conditions), AQI (European Air Quality Index combining PM2.5, PM10, ozone, NO2, and SO2, sourced from Copernicus CAMS), and Pollen (concentration in grains per cubic metre with a selectable pollen type — grass, birch, alder, mugwort, olive, ragweed, or total). These columns appear after Cloud in the table and can be toggled on or off in the column bar."
}
}
]
}
@@ -291,6 +299,17 @@
agricultural and outdoor-work planning. Soil moisture indicates whether the ground is workable
or saturated — particularly useful for motorhome and caravan pitching.
</p>
<p>
<strong>Air quality, visibility, and pollen.</strong> Three atmospheric columns sit together
after cloud cover in the table. <em>Visibility</em> shows horizontal visibility in kilometres,
sourced from the CAMS model — values below 1 km indicate fog, below 10 km suggest mist or
pollution haze. <em>AQI</em> shows the European Air Quality Index (0100+), combining PM2.5,
PM10, ozone, nitrogen dioxide, and sulphur dioxide, with colour-coded bands from Good to
Hazardous. <em>Pollen</em> shows concentration in grains per cubic metre for a selectable
pollen type — choose from total pollen, grass, birch, alder, mugwort, olive, or ragweed — with
Low / Moderate / High / Very High labels. All three are sourced from the Copernicus CAMS
air quality forecast.
</p>
<p>
<strong>The sky scope.</strong> The large circular visualisation at the top of the page shows
the sky conditions for the currently selected hour. The sky colour smoothly interpolates through
@@ -299,6 +318,19 @@
intensity scale with the actual shortwave radiation at that moment. It refreshes automatically
alongside the forecast.
</p>
<p>
<strong>Day strip weather and temperatures.</strong> Each day tab in the date selector shows the
day's forecast at a glance: a weather icon representing the dominant daytime conditions (sun,
cloud variants, or precipitation), plus the day's high and low UTCI+P felt temperatures in
warm and cool colours. The icon uses the actual daytime solar elevation and cloud category data
so it reflects the real character of the day rather than a generic symbol.
</p>
<p>
<strong>Weather event banners and popups.</strong> When notable weather conditions are forecast
— such as a frost, strong winds, fog, or a heat event — a banner appears above the forecast
table with a brief alert. Tapping or clicking the event icon opens a detailed description
popup explaining what the condition means and what to watch out for.
</p>
<h2>What is the SunScope soak-factor?</h2>
<p>
@@ -328,7 +360,7 @@
<p>
The default view — clean and uncluttered, showing the essentials for any outdoor situation.
<strong>Air temperature</strong>, <strong>wind speed</strong>, <strong>cloud cover</strong>,
<strong>precipitation</strong>, and <strong>UTCI+P</strong> — the adjusted felt temperature
<strong>visibility</strong>, <strong>precipitation</strong>, and <strong>UTCI+P</strong> — the adjusted felt temperature
that combines sun, wind, humidity, and rain into a single honest number. The right starting
point for most people most of the time.
</p>
@@ -348,8 +380,9 @@
include <strong>air temperature</strong>, <strong>relative humidity</strong> and
<strong>dew point</strong> (important for condensation and mould risk in cooler months),
<strong>wind speed</strong> for ventilation decisions, <strong>cloud cover</strong>,
and <strong>precipitation</strong> — so you know whether to open the windows or bring
the washing in.
<strong>air quality index</strong> and <strong>pollen</strong> (useful for allergy
sufferers deciding whether to open windows), and <strong>precipitation</strong> — so
you know whether to open the windows or bring the washing in.
</p>
</div>
@@ -383,8 +416,9 @@
<strong>wind speed and direction</strong> (essential for safe spraying windows),
<strong>cloud cover</strong> and <strong>sun elevation</strong> for light levels,
<strong>UTCI</strong> for heat stress on the person working outdoors,
<strong>precipitation</strong>, and the three soil columns:
<strong>surface soil temperature</strong>, <strong>soil temperature at 6 cm</strong>
<strong>precipitation</strong>, <strong>air quality index</strong> and
<strong>pollen</strong> (relevant for outdoor workers and spray-window planning),
and the three soil columns: <strong>surface soil temperature</strong>, <strong>soil temperature at 6 cm</strong>
(the root zone for most seedlings), and <strong>soil moisture</strong> — so you know
whether the ground is workable or waterlogged before you head out.
</p>
@@ -404,9 +438,9 @@
channel wind into gusts down narrow streets, concrete absorbs heat all day and radiates
it back long after sunset, and the lack of vegetation means there's no evaporative
cooling. Columns: <strong>air temperature</strong>, <strong>wind speed and direction</strong>,
<strong>cloud cover</strong>, <strong>UV-A index</strong>, <strong>burn time</strong>,
<strong>UTCI+P</strong>, <strong>precipitation</strong>, and <strong>concrete surface
temperature</strong>.
<strong>cloud cover</strong>, <strong>visibility</strong>, <strong>air quality index</strong>,
<strong>UV-A index</strong>, <strong>burn time</strong>, <strong>UTCI+P</strong>,
<strong>precipitation</strong>, and <strong>concrete surface temperature</strong>.
</p>
<p>
<strong>Beach</strong> — UV exposure at the coast is significantly higher than inland:
@@ -415,7 +449,8 @@
time</strong> for your skin type. <strong>Dew point</strong> captures the humidity that
makes hot beach days feel muggy, <strong>wind speed and direction</strong> matters for
comfort and sea state, and <strong>sun elevation</strong> tells you when the UV load
peaks. <strong>UTCI+P</strong> and <strong>precipitation</strong> round out the picture.
peaks. <strong>Visibility</strong> is included for sea-state and horizon conditions.
<strong>UTCI+P</strong> and <strong>precipitation</strong> round out the picture.
</p>
<p>
<strong>Events</strong> — for general outdoor gatherings: markets, sports fixtures, open-air
@@ -423,7 +458,8 @@
temperature</strong>, <strong>wind speed and direction</strong> (useful for exposed
grandstands and open sites), <strong>cloud cover</strong>, <strong>UV-A</strong> and
<strong>burn time</strong> for daytime events, <strong>UTCI+P</strong> for overall felt
comfort, <strong>precipitation</strong>, and <strong>concrete surface temperature</strong>
comfort, <strong>precipitation</strong>, <strong>pollen</strong> (important for
allergy-sensitive attendees), and <strong>concrete surface temperature</strong>
— relevant for anyone standing or sitting on sun-exposed hard standing for extended periods.
</p>
<p>
@@ -432,8 +468,9 @@
cold and wet conditions will actually feel, <strong>wind speed and direction</strong> for
tent and stage exposure, <strong>dew point</strong> for overnight condensation,
<strong>UV-A</strong> and <strong>burn time</strong> for daytime sun protection,
<strong>precipitation</strong> for planning, and <strong>soil moisture</strong> — the mud
indicator. Values above 0.4 m³/m³ mean the ground is saturated and vehicle access,
<strong>precipitation</strong> for planning, <strong>air quality index</strong> and
<strong>pollen</strong> — particularly relevant for multi-day outdoor exposure — and
<strong>soil moisture</strong> — the mud indicator. Values above 0.4 m³/m³ mean the ground is saturated and vehicle access,
camping areas, and walkways will be affected.
</p>
<p>
@@ -445,6 +482,7 @@
critical for wind chill at speed and for avalanche and exposure risk on open slopes.
<strong>Sun elevation</strong> indicates glare and visibility conditions on snow.
<strong>UTCI+P</strong>, <strong>air temperature</strong>, <strong>cloud cover</strong>,
<strong>visibility</strong> (critical for whiteout and low-cloud conditions on the mountain),
and <strong>precipitation</strong> (snow vs rain) complete the picture.
</p>
<p>
@@ -465,8 +503,9 @@
<strong>cloud cover</strong>, and <strong>sun elevation</strong> for visibility and glare.
UV exposure is significantly higher on open water due to reflection off the surface, so
<strong>UV-A index</strong> and <strong>burn time</strong> are included. <strong>UTCI+P</strong>
captures the combined effect of wet, windy conditions, and <strong>precipitation</strong>
rounds out the picture for passage planning.
captures the combined effect of wet, windy conditions, <strong>visibility</strong>
(essential for safe passage — fog and haze at sea are serious hazards), and
<strong>precipitation</strong> rounds out the picture for passage planning.
</p>
</div>
@@ -486,10 +525,11 @@
<p class="profile-name">⚙️ Custom · Extra</p>
<p>
Full control over which columns appear in the table. Toggle any combination of air
temperature, dew point, relative humidity, wind, direction, cloud, sun elevation, direct
and diffuse radiation, mean radiant temperature, UTCI delta, UTCI, UV-A, UV-B, burn time,
vehicle interior, indoor, managed indoor, soil temperatures, soil moisture, concrete
surface, precipitation, and UTCI+P. Build exactly the view that suits your situation.
temperature, dew point, relative humidity, wind, direction, cloud, visibility, air quality
index, pollen, sun elevation, direct and diffuse radiation, mean radiant temperature,
UTCI delta, UTCI, UV-A, UV-B, burn time, vehicle interior, indoor, managed indoor,
soil temperatures, soil moisture, concrete surface, precipitation, and UTCI+P. Build
exactly the view that suits your situation.
</p>
</div>
@@ -507,6 +547,9 @@
<tr><td><strong>Wind</strong></td><td>Mean wind speed at 10 m, with peak gust in brackets where significantly higher. Wind is the primary driver of wind chill — a key ingredient in the UTCI calculation.</td></tr>
<tr><td><strong>Dir</strong></td><td>The compass direction the wind is blowing from, shown as an arrow and abbreviated label. Useful for route planning and spray timing.</td></tr>
<tr><td><strong>Cloud</strong></td><td>Total cloud cover as a percentage of sky. High cloud blocks solar radiation, reducing daytime heating and UV exposure.</td></tr>
<tr><td><strong>Visibility km</strong></td><td>Horizontal visibility in kilometres, sourced from the Copernicus CAMS air quality model. Below 1 km indicates fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, cycling, and photography.</td></tr>
<tr><td><strong>AQI</strong></td><td>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+ = Hazardous. Sourced from Copernicus CAMS.</td></tr>
<tr><td><strong>Pollen</strong></td><td>Pollen concentration in grains per cubic metre for the selected pollen type — choose from total, grass, birch, alder, mugwort, olive, or ragweed via the dropdown. Sourced from the Copernicus CAMS pollen forecast. Low = &lt;10; Moderate = 1050; High = 50200; Very High = 200+.</td></tr>
<tr><td><strong>Sun °</strong></td><td>The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the radiation reaching the ground.</td></tr>
<tr><td><strong>Direct W/m²</strong></td><td>Shortwave solar radiation arriving as a direct beam from the sun. The primary driver of skin heating on sunny days.</td></tr>
<tr><td><strong>Diffuse W/m²</strong></td><td>Solar radiation scattered from all directions across the sky. Present even under cloud cover; contributes to the overall solar heat load.</td></tr>
@@ -672,6 +715,21 @@
<p class="faq-a">SunScope's soil moisture column shows the volumetric water content of the top centimetre of soil in m³/m³. A reading above 0.4 indicates saturated ground where a heavy vehicle is likely to sink or churn the surface. Below 0.2 is dry and firm. The Vehicle profile includes soil moisture by default precisely for motorhome and caravan users who need to assess pitch suitability before arriving on site.</p>
</details>
<details class="faq-block">
<summary class="faq-q">Does SunScope show air quality or pollen forecasts?</summary>
<p class="faq-a">Yes. Three air environment columns sit together after Cloud cover in the table: <strong>Visibility</strong> (horizontal visibility in km — useful for fog, haze, and driving conditions), <strong>AQI</strong> (the European Air Quality Index combining PM2.5, PM10, ozone, NO₂, and SO₂ in a 0100+ scale), and <strong>Pollen</strong> (concentration in grains per cubic metre with a type picker — grass, birch, alder, mugwort, olive, ragweed, or total). All three are sourced from the Copernicus CAMS forecast. Toggle them on or off using the column buttons above the table.</p>
</details>
<details class="faq-block">
<summary class="faq-q">What does the weather icon and temperature on each day tab mean?</summary>
<p class="faq-a">Each day tab in the date strip shows a small weather icon representing the dominant daytime sky conditions — a sun disc for clear days, cloud variants (wispy cirrus, scattered cumulus, overcast) for cloudy days, or a precipitation icon for rainy or snowy days. Below the icon are the day's high and low UTCI+P felt temperatures in warm and cool colours. The icon is derived from actual daytime solar elevation and cloud data, so it reflects the real character of the day rather than a generic symbol.</p>
</details>
<details class="faq-block">
<summary class="faq-q">What are the weather event banners above the forecast table?</summary>
<p class="faq-a">When notable weather conditions are forecast — such as a ground frost, strong wind warning, fog, or a heat event — a banner with a brief alert appears above the table. Tapping or clicking the event icon opens a description popup with more detail about what the condition means and what to watch out for. Multiple events can appear at once if conditions warrant.</p>
</details>
<details class="faq-block">
<summary class="faq-q">How far ahead does the forecast go?</summary>
<p class="faq-a">The free tier shows 3 days of hourly data. SunScope Extra unlocks all 14 days. The forecast auto-refreshes every 5 minutes while the page is open, so the current hour always reflects the latest available data from Open-Meteo.</p>
@@ -689,7 +747,7 @@
<details class="faq-block">
<summary class="faq-q">What data sources does SunScope use?</summary>
<p class="faq-a">Forecast data comes from <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a>, which aggregates ECMWF, GFS, and national meteorological service models. The UTCI calculation follows the Bröde et al. 2012 polynomial. Geolocation search uses the Open-Meteo geocoding API.</p>
<p class="faq-a">Forecast data comes from <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a>, which aggregates ECMWF, GFS, and national meteorological service models. The UTCI calculation follows the Bröde et al. 2012 polynomial. Air quality, visibility, and pollen data are sourced from the Open-Meteo Air Quality API, which draws on the Copernicus CAMS (Copernicus Atmosphere Monitoring Service) forecast. Geolocation search uses the Open-Meteo geocoding API.</p>
</details>
<details class="faq-block">
+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