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 # OS / editor cruft
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+79 -21
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>About SunScope · The True Temperature</title> <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" /> <meta name="robots" content="index, follow" />
<link rel="canonical" href="https://sunscope.net/about.html" /> <link rel="canonical" href="https://sunscope.net/about.html" />
@@ -36,7 +36,7 @@
"@type": "WebApplication", "@type": "WebApplication",
"name": "SunScope", "name": "SunScope",
"url": "https://sunscope.net", "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", "applicationCategory": "WeatherApplication",
"operatingSystem": "Any", "operatingSystem": "Any",
"offers": { "offers": {
@@ -187,6 +187,14 @@
"@type": "Answer", "@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." "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 agricultural and outdoor-work planning. Soil moisture indicates whether the ground is workable
or saturated — particularly useful for motorhome and caravan pitching. or saturated — particularly useful for motorhome and caravan pitching.
</p> </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> <p>
<strong>The sky scope.</strong> The large circular visualisation at the top of the page shows <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 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 intensity scale with the actual shortwave radiation at that moment. It refreshes automatically
alongside the forecast. alongside the forecast.
</p> </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> <h2>What is the SunScope soak-factor?</h2>
<p> <p>
@@ -328,7 +360,7 @@
<p> <p>
The default view — clean and uncluttered, showing the essentials for any outdoor situation. 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>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 that combines sun, wind, humidity, and rain into a single honest number. The right starting
point for most people most of the time. point for most people most of the time.
</p> </p>
@@ -348,8 +380,9 @@
include <strong>air temperature</strong>, <strong>relative humidity</strong> and include <strong>air temperature</strong>, <strong>relative humidity</strong> and
<strong>dew point</strong> (important for condensation and mould risk in cooler months), <strong>dew point</strong> (important for condensation and mould risk in cooler months),
<strong>wind speed</strong> for ventilation decisions, <strong>cloud cover</strong>, <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 <strong>air quality index</strong> and <strong>pollen</strong> (useful for allergy
the washing in. sufferers deciding whether to open windows), and <strong>precipitation</strong> — so
you know whether to open the windows or bring the washing in.
</p> </p>
</div> </div>
@@ -383,8 +416,9 @@
<strong>wind speed and direction</strong> (essential for safe spraying windows), <strong>wind speed and direction</strong> (essential for safe spraying windows),
<strong>cloud cover</strong> and <strong>sun elevation</strong> for light levels, <strong>cloud cover</strong> and <strong>sun elevation</strong> for light levels,
<strong>UTCI</strong> for heat stress on the person working outdoors, <strong>UTCI</strong> for heat stress on the person working outdoors,
<strong>precipitation</strong>, and the three soil columns: <strong>precipitation</strong>, <strong>air quality index</strong> and
<strong>surface soil temperature</strong>, <strong>soil temperature at 6 cm</strong> <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 (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. whether the ground is workable or waterlogged before you head out.
</p> </p>
@@ -404,9 +438,9 @@
channel wind into gusts down narrow streets, concrete absorbs heat all day and radiates 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 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>, 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>cloud cover</strong>, <strong>visibility</strong>, <strong>air quality index</strong>,
<strong>UTCI+P</strong>, <strong>precipitation</strong>, and <strong>concrete surface <strong>UV-A index</strong>, <strong>burn time</strong>, <strong>UTCI+P</strong>,
temperature</strong>. <strong>precipitation</strong>, and <strong>concrete surface temperature</strong>.
</p> </p>
<p> <p>
<strong>Beach</strong> — UV exposure at the coast is significantly higher than inland: <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 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 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 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>
<p> <p>
<strong>Events</strong> — for general outdoor gatherings: markets, sports fixtures, open-air <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 temperature</strong>, <strong>wind speed and direction</strong> (useful for exposed
grandstands and open sites), <strong>cloud cover</strong>, <strong>UV-A</strong> and 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 <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. — relevant for anyone standing or sitting on sun-exposed hard standing for extended periods.
</p> </p>
<p> <p>
@@ -432,8 +468,9 @@
cold and wet conditions will actually feel, <strong>wind speed and direction</strong> for 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, 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>UV-A</strong> and <strong>burn time</strong> for daytime sun protection,
<strong>precipitation</strong> for planning, and <strong>soil moisture</strong> — the mud <strong>precipitation</strong> for planning, <strong>air quality index</strong> and
indicator. Values above 0.4 m³/m³ mean the ground is saturated and vehicle access, <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. camping areas, and walkways will be affected.
</p> </p>
<p> <p>
@@ -445,6 +482,7 @@
critical for wind chill at speed and for avalanche and exposure risk on open slopes. 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>Sun elevation</strong> indicates glare and visibility conditions on snow.
<strong>UTCI+P</strong>, <strong>air temperature</strong>, <strong>cloud cover</strong>, <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. and <strong>precipitation</strong> (snow vs rain) complete the picture.
</p> </p>
<p> <p>
@@ -465,8 +503,9 @@
<strong>cloud cover</strong>, and <strong>sun elevation</strong> for visibility and glare. <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 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> <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> captures the combined effect of wet, windy conditions, <strong>visibility</strong>
rounds out the picture for passage planning. (essential for safe passage — fog and haze at sea are serious hazards), and
<strong>precipitation</strong> rounds out the picture for passage planning.
</p> </p>
</div> </div>
@@ -486,10 +525,11 @@
<p class="profile-name">⚙️ Custom · Extra</p> <p class="profile-name">⚙️ Custom · Extra</p>
<p> <p>
Full control over which columns appear in the table. Toggle any combination of air 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 temperature, dew point, relative humidity, wind, direction, cloud, visibility, air quality
and diffuse radiation, mean radiant temperature, UTCI delta, UTCI, UV-A, UV-B, burn time, index, pollen, sun elevation, direct and diffuse radiation, mean radiant temperature,
vehicle interior, indoor, managed indoor, soil temperatures, soil moisture, concrete UTCI delta, UTCI, UV-A, UV-B, burn time, vehicle interior, indoor, managed indoor,
surface, precipitation, and UTCI+P. Build exactly the view that suits your situation. soil temperatures, soil moisture, concrete surface, precipitation, and UTCI+P. Build
exactly the view that suits your situation.
</p> </p>
</div> </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>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>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>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>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>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> <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> <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>
<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"> <details class="faq-block">
<summary class="faq-q">How far ahead does the forecast go?</summary> <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> <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"> <details class="faq-block">
<summary class="faq-q">What data sources does SunScope use?</summary> <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>
<details class="faq-block"> <details class="faq-block">
+1 -2
View File
@@ -31,7 +31,6 @@
JetBrains Mono numeric monospace table data JetBrains Mono numeric monospace table data
════════════════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════════════════ */
/* ── 1. FONTS + RESET ───────────────────────────────────────────────── */ /* ── 1. FONTS + RESET ───────────────────────────────────────────────── */
/* Font loading is handled in index.html via a non-blocking <link rel="preload"> /* Font loading is handled in index.html via a non-blocking <link rel="preload">
@@ -177,7 +176,7 @@ body {
/* ── Mobile nav drawer ──────────────────────────────────────────────── */ /* ── Mobile nav drawer ──────────────────────────────────────────────── */
@media (max-width: 640px) { @media (max-width: 640px) {
.utci-app { .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 */ /* 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. header.css — Page header: title wordmark, UTCI dial, and search box.
════════════════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════════════════ */
/* ── 4. HEADER (title + dial + search, three-column grid) ──────────── */ /* ── 4. HEADER (title + dial + search, three-column grid) ──────────── */
.utci-header { .utci-header {
@@ -45,15 +44,6 @@
color: #1e1208; 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..." */ /* "See the sun the way..." */
.utci-tagline { .utci-tagline {
font-family: Fraunces, serif; font-family: Fraunces, serif;
@@ -63,29 +53,6 @@
margin-top: 14px; 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" */ /* "↳ Pangbourne, Berkshire" */
.utci-current-loc { .utci-current-loc {
font-family: Fraunces, serif; font-family: Fraunces, serif;
+103 -89
View File
@@ -3,7 +3,6 @@
now-row and night-row decorations. now-row and night-row decorations.
════════════════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════════════════ */
/* ── 5. DAY TABS (today / tomorrow / etc.) ─────────────────────────── */ /* ── 5. DAY TABS (today / tomorrow / etc.) ─────────────────────────── */
/* Note: each tab's background colour comes from inline styles in /* Note: each tab's background colour comes from inline styles in
app.js (the confidence bands). This file only handles spacing, app.js (the confidence bands). This file only handles spacing,
@@ -241,31 +240,6 @@
color: #fff; 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 ── */ /* ── Vehicle group: select + windows-open checkbox fused together ── */
.col-toggle-group { .col-toggle-group {
display: inline-flex; display: inline-flex;
@@ -287,69 +261,12 @@
} }
.col-toggle-group .grouped-left { .col-toggle-group .grouped-left {
border-right: none !important; 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:only-child .grouped-left,
.col-toggle-group .cs-wrap:last-child .grouped-left { .col-toggle-group .cs-wrap:last-child .grouped-left {
border-right: 1.5px solid #c9b08a !important; border-right: 1.5px solid #c9b08a !important;
border-radius: 20px !important; border-radius: 0 !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);
} }
@@ -636,10 +553,6 @@
.col-info-close:hover { .col-info-close:hover {
color: #4a3420; 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 ─────────────────────────────── */ /* ── 8. NOW-ROW & NIGHT-ROW DECORATIONS ─────────────────────────────── */
@@ -715,3 +628,104 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; 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. ui.css — Status banners, legend, about block, footer, and responsive.
════════════════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════════════════ */
/* ── EVENT BANNER ───────────────────────────────────────────────────── */ /* ── EVENT BANNER ───────────────────────────────────────────────────── */
/* Animated slide-down banner for cosmic/weather/promo events. */ /* Animated slide-down banner for cosmic/weather/promo events. */
@@ -196,9 +195,8 @@
.almanac-entry-desc { .almanac-entry-desc {
font-family: Manrope, sans-serif; font-family: Manrope, sans-serif;
font-size: 12px; font-size: 12px;
color: '#4a3420';
line-height: 1.55;
color: #4a3420; color: #4a3420;
line-height: 1.55;
} }
.almanac-entry-visibility { .almanac-entry-visibility {
display: inline-block; 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 ─────────────────────────────────────────── */ /* ── CUSTOM SELECT DROPDOWN ─────────────────────────────────────────── */
/* Cross-platform replacement for native <select> on the col-toggles bar */ /* Cross-platform replacement for native <select> on the col-toggles bar */
@@ -252,6 +295,7 @@
padding: 5px 10px 5px 13px; padding: 5px 10px 5px 13px;
cursor: pointer; cursor: pointer;
border: 1.5px solid #c9b08a; border: 1.5px solid #c9b08a;
border-radius: 0;
background: #f5edd6; background: #f5edd6;
color: #6b4228; color: #6b4228;
font-family: Manrope, sans-serif; font-family: Manrope, sans-serif;
@@ -299,19 +343,15 @@
transform: rotate(225deg); transform: rotate(225deg);
margin-bottom: -1px; margin-bottom: -1px;
} }
.cs-wrap.open .cs-arrow-old {
transform: rotate(180deg);
}
/* Grouped left (fused with checkbox pill) */ /* Grouped left (fused with checkbox pill) */
.cs-btn.grouped-left { .cs-btn.grouped-left {
border-right: none; border-right: none;
border-radius: 20px 0 0 20px; border-radius: 0;
} }
.cs-btn.grouped-left:only-child, .cs-btn.grouped-left:only-child,
.cs-btn.grouped-left.last-child { .cs-btn.grouped-left.last-child {
border-right: 1.5px solid #c9b08a; border-right: 1.5px solid #c9b08a;
border-radius: 20px; border-radius: 0;
} }
/* The floating dropdown panel */ /* The floating dropdown panel */
@@ -384,7 +424,7 @@
border-left: none; border-left: none;
margin-left: -1px; margin-left: -1px;
padding-left: 11px; padding-left: 11px;
border-radius: 0 20px 20px 0; border-radius: 0;
background: #f5edd6; background: #f5edd6;
color: #6b4228; color: #6b4228;
user-select: none; user-select: none;
+236 -45
View File
@@ -58,6 +58,7 @@ export function UTCIForecast() {
}; };
const [forecast, setForecast] = useState(null); // raw Open-Meteo response 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 [loading, setLoading] = useState(false); // true while fetching
const [error, setError] = useState(null); // fetch error message const [error, setError] = useState(null); // fetch error message
const [searchQuery, setSearchQuery] = useState(''); // text in the search box const [searchQuery, setSearchQuery] = useState(''); // text in the search box
@@ -117,11 +118,10 @@ export function UTCIForecast() {
el.scrollBy({ left: dir * 200, behavior: 'smooth' }); el.scrollBy({ left: dir * 200, behavior: 'smooth' });
}; };
// ─── PRO TIER STUB ──────────────────────────────────────────────────── // ─── PRO TIER ─────────────────────────────────────────────────────────
// FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE. // Initialise from URL param (?pro=1, set by Stripe after checkout) or
// When this is wired to real billing/auth, replace `useState(false)` // from localStorage (returning subscriber). Cleans the URL param
// with a check against the logged-in user. // immediately so it doesn't stay visible in the address bar.
//const [isPro, setIsPro] = useState(false);
const [isPro, setIsPro] = useState(() => { const [isPro, setIsPro] = useState(() => {
// Check URL param first (just returned from Stripe checkout) // Check URL param first (just returned from Stripe checkout)
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -145,53 +145,53 @@ export function UTCIForecast() {
basic: { basic: {
label: 'Basic', label: 'Basic',
icon: '🌡️', 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: { home: {
label: 'Home', label: 'Home',
icon: '🏠', 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: { vehicle: {
label: 'Vehicle', label: 'Vehicle',
icon: '🚗', 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: { farming: {
label: 'Farming', label: 'Farming',
icon: '🌾', 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: { outdoors: {
label: 'Places', label: 'Places',
icon: '🌤️', icon: '🌤️',
// Default cols match the first variant (urban). Switching variant updates visibleCols. // 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: { alltemps: {
label: 'Temps', label: 'Temps',
icon: '🌡️', icon: '🌡️',
proOnly: true, 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: { custom: {
label: 'Custom', label: 'Custom',
icon: '⚙️', icon: '⚙️',
proOnly: true, 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. // Places sub-variants — each has its own column set.
// Selecting a variant applies its cols to visibleCols. // Selecting a variant applies its cols to visibleCols.
const OUTDOORS_VARIANTS = { 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 } }, 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 } }, 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 } }, 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 } }, 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 } }, 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 } }, 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 } }, 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 // Current active filter profile
@@ -200,7 +200,7 @@ export function UTCIForecast() {
// Which columns appear in the hourly table by default. // Which columns appear in the hourly table by default.
// true = visible on first load (and the only ones free users see) // true = visible on first load (and the only ones free users see)
// false = hidden by default (Pro users can toggle these on) // 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] })); const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
// Skin type for the sunburn-time column. Fitzpatrick II is typical UK fair. // 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 [buildingType, setBuildingType] = useState('brick');
const [indoorManaged, setIndoorManaged] = useState(false); const [indoorManaged, setIndoorManaged] = useState(false);
const [indoorMode, setIndoorMode] = useState('off'); 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 searchTimeout = useRef(null);
const activateProfile = (key) => { 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.' }, 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.' }, 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.' }, 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) => { const calcPopupPos = (thEl) => {
@@ -689,7 +712,7 @@ export function UTCIForecast() {
`wind_speed_10m,wind_direction_10m,wind_gusts_10m,` + `wind_speed_10m,wind_direction_10m,wind_gusts_10m,` +
`direct_radiation,diffuse_radiation,shortwave_radiation,` + `direct_radiation,diffuse_radiation,shortwave_radiation,` +
`cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` + `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` + `soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`; `&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
const r = await fetch(url); const r = await fetch(url);
@@ -699,8 +722,45 @@ export function UTCIForecast() {
finally { setLoading(false); } finally { setLoading(false); }
} }
load(); load();
loadAirQuality(location);
}, [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 ───────────────────────────── // ─── AUTO-REFRESH — tick every 5 minutes ─────────────────────────────
// Updates `now` so the scope always shows the current hour's data. // Updates `now` so the scope always shows the current hour's data.
// Also refetches the forecast so fresh API data comes in automatically. // 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,` + `wind_speed_10m,wind_direction_10m,wind_gusts_10m,` +
`direct_radiation,diffuse_radiation,shortwave_radiation,` + `direct_radiation,diffuse_radiation,shortwave_radiation,` +
`cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` + `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` + `soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`; `&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
const r = await fetch(url); const r = await fetch(url);
if (r.ok) setForecast(await r.json()); if (r.ok) setForecast(await r.json());
} catch (_) { /* silently ignore refresh errors */ } } 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(); refresh();
}, FIVE_MIN); }, FIVE_MIN);
@@ -736,19 +806,27 @@ export function UTCIForecast() {
// Take the raw API arrays and stitch them into one object per hour, // Take the raw API arrays and stitch them into one object per hour,
// calculating UTCI + soak-factor for each row. This is what gets // calculating UTCI + soak-factor for each row. This is what gets
// displayed in the table. // displayed in the table.
//
// Open-Meteo with timezone=auto returns local wall-clock strings like // Open-Meteo with timezone=auto returns local wall-clock strings like
// "2026-05-13T14:00" — no Z, no offset suffix. We need two things: // "2026-05-13T14:00" — no Z suffix. We use two forms:
// 1. The wall-clock hour for display & day grouping (just slice the string) // • String slices (iso.slice(...)) for display & day grouping
// 2. The true UTC instant for solarElevationDeg (which uses .getUTC* internally) // • A true UTC Date for solarElevationDeg (see per-row comment below)
// 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.
const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000; 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 hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => {
const h = forecast.hourly; const h = forecast.hourly;
const Ta = h.temperature_2m[i]; const Ta = h.temperature_2m[i];
@@ -776,19 +854,28 @@ export function UTCIForecast() {
// For solarElevationDeg (which uses .getUTC* internally) we need the // For solarElevationDeg (which uses .getUTC* internally) we need the
// true UTC instant: treat the local time as UTC then subtract the offset. // 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 ✓ // 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); // True UTC instant: treat the local wall-clock string as UTC, then
// dt kept for SkyScope / backward compat — same as dtUTC. // subtract the offset. e.g. Brisbane UTC+10, local 14:00 → UTC 04:00.
const dt = dtUTC; const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs);
const elev = solarElevationDeg(location.lat, location.lon, dtUTC); const elev = solarElevationDeg(location.lat, location.lon, dt);
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent); const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent);
const eh = vaporPressureHpa(Ta, RH); const eh = vaporPressureHpa(Ta, RH);
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev); const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
const utci = utciApprox(Ta, Tmrt, va, eh); const utci = utciApprox(Ta, Tmrt, va, eh);
const utciAdj = utci + precipPenalty(precip, snow, va); const utciAdj = utci + precipPenalty(precip, snow, va);
// Derived // Derived
const compass = windCompass8(wd); const compass = windCompass8(wd);
const { uvA, uvB } = uvSplit(uv, elev); const { uvA, uvB } = uvSplit(uv, elev);
const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh); 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 { return {
iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob, iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob,
cc, ccLow, ccMid, ccHigh, cloudCat, cc, ccLow, ccMid, ccHigh, cloudCat,
@@ -796,6 +883,8 @@ export function UTCIForecast() {
precip, snow, precip, snow,
soilT0, soilT6, soilM, concreteT, vehicleT, soilT0, soilT6, soilM, concreteT, vehicleT,
elev, Tmrt, utci, utciAdj, eh, compass, elev, Tmrt, utci, utciAdj, eh, compass,
visKm, aqi,
grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen,
}; };
}) : []; }) : [];
@@ -921,7 +1010,6 @@ export function UTCIForecast() {
return html` return html`
<div class="event-banner" <div class="event-banner"
style=${{ background: ev.color, color: ev.textColor }} style=${{ background: ev.color, color: ev.textColor }}
onMouseEnter=${() => {}}
> >
<span class="event-banner-emoji">${ev.emoji}</span> <span class="event-banner-emoji">${ev.emoji}</span>
<div class="event-banner-body"> <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 utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v));
const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null; const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null;
const dayLo = utcipVals.length ? Math.round(Math.min(...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` return html`
<button <button
key=${d.key} key=${d.key}
@@ -1095,13 +1203,18 @@ export function UTCIForecast() {
<span class="utci-day-date" style=${{ color: '#5a3f24' }}> <span class="utci-day-date" style=${{ color: '#5a3f24' }}>
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })} ${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
</span> </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` ${dayHi !== null && html`
<span style=${{ <span style=${{
display: 'block', display: 'block',
fontFamily: 'Fraunces, serif', fontFamily: 'Fraunces, serif',
fontStyle: 'italic', fontStyle: 'italic',
fontSize: '11px', fontSize: '11px',
marginTop: '3px', marginTop: '2px',
letterSpacing: 0, letterSpacing: 0,
textTransform: 'none', textTransform: 'none',
}}> }}>
@@ -1209,8 +1322,8 @@ export function UTCIForecast() {
}}> }}>
<a <a
href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style=${{ style=${{
display: 'inline-block', display: 'inline-block',
padding: '10px 18px', padding: '10px 18px',
@@ -1340,7 +1453,7 @@ export function UTCIForecast() {
}} }}
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`; >${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
})} })}
<span class="profile-divider" aria-hidden="true"></span> <span class="profile-divider" aria-hidden="true"></span>
<${CustomSelect} <${CustomSelect}
key="activities" key="activities"
value=${activityValue} 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['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['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>`} ${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['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['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>`} ${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.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.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.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.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.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>`} ${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>${Math.round(r.cc)}</span>
</span> </span>
</td>`} </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.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.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>`} ${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). // components.js — Preact UI components (SVG widgets and controls).
//
// 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
// //
// All components use the html`` tagged template from htm + Preact. // All components use the html`` tagged template from htm + Preact.
// They depend on utils.js (skyFillForElev, grassFillForElev, // No external state; they depend only on utils.js and events.js.
// moonPhaseFraction) but have no other external state. //
// 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'; import { h, Fragment } from '../vendor/preact.js';
@@ -91,8 +93,9 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
>${hidingLabel || ('Hide ' + hideLabel)}</span> >${hidingLabel || ('Hide ' + hideLabel)}</span>
<div class="cs-divider"></div> <div class="cs-divider"></div>
`} `}
${options.map(opt => html` ${options.map(opt => opt.divider
<span ? html`<div key=${opt.value} class="cs-divider"></div>`
: html`<span
key=${opt.value} key=${opt.value}
class=${`cs-option${opt.value === value && isOn ? ' selected' : ''}${opt.disabled ? ' disabled' : ''}`} class=${`cs-option${opt.value === value && isOn ? ' selected' : ''}${opt.disabled ? ' disabled' : ''}`}
role="option" role="option"
@@ -101,8 +104,8 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
onChange(opt.value); onChange(opt.value);
setOpen(false); setOpen(false);
}} }}
>${opt.label}</span> >${opt.label}</span>`
`)} )}
</div> </div>
`} `}
</span>`; </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)} ${line(size * 0.30, y0 + size * 0.18, size * 0.92, y0 + size * 0.18, ink, sw * 0.82, 0.72)}
</g>`; </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` return html`
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`} <svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}> style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
${category === 'clear' && html` ${category === 'clear' && html`
${line(size * 0.26, size * 0.50, size * 0.74, size * 0.50, brass, sw, 0.9)} ${sun(size * 0.50, size * 0.38, size * 0.16)}`}
${line(size * 0.38, size * 0.64, size * 0.62, size * 0.64, muted, sw * 0.75, 0.7)}`}
${category === 'wispy' && html` ${category === 'wispy' && html`
${mist(size * 0.38, brass)} ${mist(size * 0.18, brass)}
${line(size * 0.18, size * 0.78, size * 0.60, size * 0.78, muted, sw * 0.7, 0.6)}`} ${line(size * 0.18, size * 0.56, size * 0.60, size * 0.56, muted, sw * 0.7, 0.6)}`}
${category === 'scattered' && html` ${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} /> 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` ${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" /> 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} />`} stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />`}
</svg>`; </svg>`;
} }
+55 -26
View File
@@ -193,7 +193,7 @@ const COSMIC_CALENDAR = [
// ── 2027 METEOR SHOWERS ───────────────────────────────────────────── // ── 2027 METEOR SHOWERS ─────────────────────────────────────────────
{ {
id: 'quadrantids-2027', id: 'quadrantids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F), emoji: '☄️',
title: 'Quadrantid Meteor Shower', 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.', 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', color: '#2a1a5a', textColor: '#e8d8ff',
@@ -202,7 +202,7 @@ const COSMIC_CALENDAR = [
}, },
{ {
id: 'lyrids-2027', id: 'lyrids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F), emoji: '☄️',
title: 'Lyrid Meteor Shower', 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.', 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', color: '#2a1a5a', textColor: '#e8d8ff',
@@ -211,7 +211,7 @@ const COSMIC_CALENDAR = [
}, },
{ {
id: 'eta-aquariids-2027', id: 'eta-aquariids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F), emoji: '☄️',
title: 'Eta Aquariid Meteor Shower', 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.', 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', color: '#2a1a5a', textColor: '#e8d8ff',
@@ -220,7 +220,7 @@ const COSMIC_CALENDAR = [
}, },
{ {
id: 'perseids-2027', id: 'perseids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F), emoji: '☄️',
title: 'Perseid Meteor Shower', 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.', 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', color: '#3a1a00', textColor: '#ffe8c0',
@@ -229,7 +229,7 @@ const COSMIC_CALENDAR = [
}, },
{ {
id: 'orionids-2027', id: 'orionids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F), emoji: '☄️',
title: 'Orionid Meteor Shower', title: 'Orionid Meteor Shower',
message: 'The Orionid shower peaks — fast, bright meteors from Halley\'s Comet debris. Up to 25/hour.', message: 'The Orionid shower peaks — fast, bright meteors from Halley\'s Comet debris. Up to 25/hour.',
color: '#2a1a5a', textColor: '#e8d8ff', color: '#2a1a5a', textColor: '#e8d8ff',
@@ -238,7 +238,7 @@ const COSMIC_CALENDAR = [
}, },
{ {
id: 'leonids-2027', id: 'leonids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F), emoji: '☄️',
title: 'Leonid Meteor Shower', title: 'Leonid Meteor Shower',
message: 'The Leonid shower peaks — fast meteors from comet Tempel-Tuttle, up to 15/hour.', message: 'The Leonid shower peaks — fast meteors from comet Tempel-Tuttle, up to 15/hour.',
color: '#2a1a5a', textColor: '#e8d8ff', color: '#2a1a5a', textColor: '#e8d8ff',
@@ -247,7 +247,7 @@ const COSMIC_CALENDAR = [
}, },
{ {
id: 'geminids-2027', id: 'geminids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F), emoji: '☄️',
title: 'Geminid Meteor Shower', title: 'Geminid Meteor Shower',
message: 'The Geminids peak — the finest shower of the year, up to 150 meteors/hour, visible even before midnight.', message: 'The Geminids peak — the finest shower of the year, up to 150 meteors/hour, visible even before midnight.',
color: '#3a1a00', textColor: '#ffe8c0', color: '#3a1a00', textColor: '#ffe8c0',
@@ -256,7 +256,7 @@ const COSMIC_CALENDAR = [
}, },
{ {
id: 'ursids-2027', id: 'ursids-2027',
emoji: String.fromCodePoint(0x2604, 0xFE0F), emoji: '☄️',
title: 'Ursid Meteor Shower', title: 'Ursid Meteor Shower',
message: 'The Ursid shower peaks near the winter solstice — circumpolar, best from northern latitudes.', message: 'The Ursid shower peaks near the winter solstice — circumpolar, best from northern latitudes.',
color: '#2a1a5a', textColor: '#e8d8ff', color: '#2a1a5a', textColor: '#e8d8ff',
@@ -267,7 +267,7 @@ const COSMIC_CALENDAR = [
// ── 2027 ECLIPSES ─────────────────────────────────────────────────── // ── 2027 ECLIPSES ───────────────────────────────────────────────────
{ {
id: 'annular-solar-eclipse-2027-feb', id: 'annular-solar-eclipse-2027-feb',
emoji: String.fromCodePoint(0x1F311), emoji: '🌑',
title: 'Annular Solar Eclipse', 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.', 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', color: '#1a0a2a', textColor: '#d8c8f8',
@@ -276,7 +276,7 @@ const COSMIC_CALENDAR = [
}, },
{ {
id: 'total-solar-eclipse-2027-aug', id: 'total-solar-eclipse-2027-aug',
emoji: String.fromCodePoint(0x1F311), emoji: '🌑',
title: 'Total Solar Eclipse', 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.', 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', color: '#1a0a2a', textColor: '#d8c8f8',
@@ -287,7 +287,7 @@ const COSMIC_CALENDAR = [
// ── 2027 PLANETARY EVENTS ─────────────────────────────────────────── // ── 2027 PLANETARY EVENTS ───────────────────────────────────────────
{ {
id: 'mars-opposition-2027', id: 'mars-opposition-2027',
emoji: String.fromCodePoint(0x1F534), emoji: '🔴',
title: 'Mars at Opposition', 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.', 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', color: '#2a1520', textColor: '#ffc8d8',
@@ -296,21 +296,23 @@ const COSMIC_CALENDAR = [
}, },
{ {
id: 'venus-jupiter-conjunction-2027', id: 'venus-jupiter-conjunction-2027',
emoji: String.fromCodePoint(0x1FA90), emoji: '🪐',
title: 'Venus & Jupiter Conjunction', 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.', 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', color: '#1a2a3a', textColor: '#c8e8ff',
type: 'cosmic', nightOnly: true, type: 'cosmic', nightOnly: true,
start: '2027-08-23', end: '2027-08-27', peak: '2027-08-25', start: '2027-08-23', end: '2027-08-27', peak: '2027-08-25',
}, },
]; ];
// ─── WEATHER-DERIVED EVENTS ─────────────────────────────────────────────── // ─── WEATHER-DERIVED EVENTS ───────────────────────────────────────────────
// These are computed from the actual forecast data. // Computed in real-time from the forecast data.
// Each checker receives (rows, location) and returns an event object or null. // Each checker function receives (rows [, location]) and returns an event
// rows = today's hourlyRows array. location = { lat, lon, name, country }. // 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) { function checkStargazing(rows) {
// Great stargazing: mostly clear night hours with low cloud // 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 ───────────────────────────────────────────────────────── // ─── MAIN EXPORT ─────────────────────────────────────────────────────────
// Returns ALL active events for the given rows/date as an array. // Returns ALL active events for the given rows/date as an array.
// Empty array = nothing active. // Empty array = nothing active.
@@ -765,7 +794,9 @@ export function getActiveEvents(rows, location) {
const dateStr = (rows && rows.length > 0) const dateStr = (rows && rows.length > 0)
? rows[0].iso.slice(0, 10) ? rows[0].iso.slice(0, 10)
: new Date().toISOString().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) // 3. Weather-derived events (all that match, not just first)
const weatherEvents = []; const weatherEvents = [];
@@ -802,16 +833,14 @@ export function getLensEvent(events) {
} }
// ─── LENS OVERLAY RENDERER ─────────────────────────────────────────────── // ─── LENS OVERLAY RENDERER ───────────────────────────────────────────────
// Returns SVG group content for the event overlay in the big ScopeReticle. // Returns an SVG string for the event overlay inside the big ScopeReticle.
// Rendered INSIDE the lens clip and BELOW the glass shine/shade layers. // Rendered INSIDE the lens clip path, BELOW the glass shine/shade layers.
// cx, cy = lens centre; lensR = lens radius. // cx, cy = lens centre coords; lensR = lens radius.
export function getLensOverlaySVG(event, cx, cy, lensR) { export function getLensOverlaySVG(event, cx, cy, lensR) {
if (!event) return null; if (!event) return null;
const id = event.id; const id = event.id;
if (event.emoji === String.fromCodePoint(0x2604, 0xFE0F)) { if (event.emoji === '☄️') {
return [ return [
'<g opacity="0.55" clip-path="url(#scope-lens-clip)">', '<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"/>', '<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>'; + '<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">' 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)" />' + '<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>'; + '<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">' 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+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>'; + '<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. // physics.js — Physical constants and meteorological calculations.
// //
// Exports: // All numbers are peer-reviewed constants or coefficients. Nothing in
// SIGMA, EPSILON_P, A_K, ALBEDO_GRASS (radiation constants) // here should need editing unless the underlying science changes.
// 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)
// //
// Nothing in here should need editing unless the underlying science // Exports (in order of appearance):
// changes. All numbers are peer-reviewed constants or coefficients. // 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'; 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 // > 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. // Two-pass function: call with the full arrays of hourly Ta and globalRad.
// Returns an array of indoor temperatures, one per hour. // Returns an array of indoor temperatures, one per hour.
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'. // 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. // 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) { export function vaporPressureHpa(Ta, RH) {
const es = 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta)); const es = 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
return es * (RH / 100); 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) { export function solarElevationDeg(lat, lon, dateUTC) {
const start = Date.UTC(dateUTC.getUTCFullYear(), 0, 0); const start = Date.UTC(dateUTC.getUTCFullYear(), 0, 0);
const diff = dateUTC - start; const diff = dateUTC - start;
@@ -374,7 +369,21 @@ export function solarElevationDeg(lat, lon, dateUTC) {
return (Math.PI / 2 - zenith) * (180 / Math.PI); 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) { export function calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) {
const TaK = Ta + 273.15; const TaK = Ta + 273.15;
let fp = 0; let fp = 0;
+32 -19
View File
@@ -1,22 +1,26 @@
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
// utils.js — Pure helper functions and lookup tables. // utils.js — Pure helper functions and lookup tables.
// //
// Exports: // No side-effects, no DOM access, no API calls. All functions are safe
// utciCategory(u) UTCI stress band → {label,bg,fg} // to call server-side or in tests.
// precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor //
// windCompass8(deg) bearing → {label, snapped} // Exports (in order of appearance):
// uvSplit(uv, elevDeg) total UV → {uvA, uvB} // utciCategory(u) UTCI stress band → {label,bg,fg}
// SKIN_TYPES Fitzpatrick skin type table // precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor (°C penalty)
// sunburnMinutes(uv, skinType) minutes to MED // windCompass8(deg) bearing → {label, snapped}
// burnLabel(mins) formats burn time as "12m"/"1.5h" // uvSplit(uv, elevDeg) total UV index → {uvA, uvB} estimate
// VEHICLE_TYPES vehicle presets for cabin heat model // SKIN_TYPES Fitzpatrick IVI lookup table
// BUILDING_TYPES building presets for indoor heat model // sunburnMinutes(uv, skinType) minutes to MED (sunburn threshold)
// cloudCategory(total,low,mid,high) → 'clear'|'wispy'|'scattered'|'overcast' // burnLabel(mins) formats burn time as "12m" / "1.5h"
// confidenceBand(i) day-tab gradient + label // VEHICLE_TYPES vehicle presets for cabin heat model
// moonPhaseFraction(date) 0..1 synodic phase // BUILDING_TYPES building presets for indoor heat model
// moonGlyph(p) phase fraction → emoji // cloudCategory(total,low,mid,high) → 'clear'|'wispy'|'scattered'|'overcast'
// skyGradientForElev(e, isRising) solar elevation + direction → {top,bot} sky gradient // confidenceBand(i) day-tab gradient colour + label
// grassFillForElev(e) solar elevation → {top,bot} hex // 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; return p;
} }
export function moonGlyph(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); 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) { export function skyFillForElev(e, isRising = false) {
return skyGradientForElev(e, isRising).bot; 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) { export function grassFillForElev(e) {
if (e > 60) return { top: '#92cc50', bot: '#5e9630' }; // blazing noon if (e > 60) return { top: '#92cc50', bot: '#5e9630' }; // blazing noon
if (e > 30) return { top: '#86c44a', bot: '#558a2e' }; // bright midday if (e > 30) return { top: '#86c44a', bot: '#558a2e' }; // bright midday