Beta 8
Improved scope Improved profiles
This commit is contained in:
+128
@@ -104,6 +104,134 @@
|
|||||||
a more honest, real-world comfort score for anyone heading outdoors.
|
a more honest, real-world comfort score for anyone heading outdoors.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h2>Forecast profiles</h2>
|
||||||
|
<p>
|
||||||
|
SunScope organises its columns into <strong>profiles</strong> — curated sets of data tailored
|
||||||
|
to different activities and use cases. Rather than showing every column at once, each profile
|
||||||
|
surfaces the information that actually matters for that situation. You can switch profiles
|
||||||
|
using the row of buttons above the forecast table, or switch to <strong>Custom</strong> to
|
||||||
|
pick individual columns yourself.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="profile-block">
|
||||||
|
<p class="profile-name">🏙️ Urban</p>
|
||||||
|
<p>
|
||||||
|
Designed for anyone moving around a city on foot — commuting, shopping, sightseeing.
|
||||||
|
The urban environment creates its own microclimate: buildings channel wind into gusts down
|
||||||
|
narrow streets, concrete absorbs heat all day and radiates it back long after sunset, and
|
||||||
|
the lack of vegetation means there's no evaporative cooling. The Urban profile shows
|
||||||
|
<strong>air temperature</strong>, <strong>wind speed and direction</strong> (useful for
|
||||||
|
finding sheltered routes), <strong>cloud cover</strong>, <strong>UV-A index</strong> and
|
||||||
|
<strong>burn time</strong> for sun protection, <strong>UTCI+P</strong> for overall felt
|
||||||
|
comfort, <strong>precipitation</strong>, and <strong>concrete surface temperature</strong>
|
||||||
|
— the heat radiating up from pavement that standard forecasts completely ignore.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-block">
|
||||||
|
<p class="profile-name">🌾 Farming</p>
|
||||||
|
<p>
|
||||||
|
Built for anyone working the land — farmers, growers, and market gardeners. Decision-making
|
||||||
|
in agriculture is driven by conditions at ground level, not just the air. The Farming profile
|
||||||
|
shows <strong>air temperature</strong>, <strong>relative humidity</strong> and
|
||||||
|
<strong>dew point</strong> (critical for frost risk assessment and disease pressure on crops),
|
||||||
|
<strong>wind speed and direction</strong> (essential for safe spraying windows),
|
||||||
|
<strong>cloud cover</strong> and <strong>sun elevation</strong> for light levels,
|
||||||
|
<strong>UTCI</strong> for heat stress on the person working outdoors,
|
||||||
|
<strong>precipitation</strong>, and the three soil columns:
|
||||||
|
<strong>surface soil temperature</strong>, <strong>soil temperature at 6 cm</strong>
|
||||||
|
(the root zone for most seedlings), and <strong>soil moisture</strong> — so you know
|
||||||
|
whether the ground is workable or waterlogged before you head out.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-block">
|
||||||
|
<p class="profile-name">⛵ Sailing</p>
|
||||||
|
<p>
|
||||||
|
Wind is everything on the water, and the conditions out at sea can be dramatically different
|
||||||
|
from what the shore feels like. The Sailing profile leads with what matters most:
|
||||||
|
<strong>air temperature</strong> (hypothermia is a real risk — water conducts heat away from
|
||||||
|
the body twenty-five times faster than air), <strong>dew point</strong> (condensation on
|
||||||
|
sails and equipment is a practical concern at dawn and dusk), <strong>wind speed</strong>
|
||||||
|
with gusts, <strong>wind direction</strong>, <strong>cloud cover</strong>, and
|
||||||
|
<strong>sun elevation</strong> for visibility and glare. UV exposure is significantly higher
|
||||||
|
on open water due to reflection off the surface, so <strong>UV-A index</strong> and
|
||||||
|
<strong>burn time</strong> are included. <strong>UTCI+P</strong> captures the combined
|
||||||
|
misery of wet, windy conditions, and <strong>precipitation</strong> rounds out the picture
|
||||||
|
for passage planning.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-block">
|
||||||
|
<p class="profile-name">🚗 Vehicle</p>
|
||||||
|
<p>
|
||||||
|
Covers the parked and travelling vehicle experience — especially useful for anyone with
|
||||||
|
children or pets, motorhome travellers, and caravan owners. The headline column is
|
||||||
|
<strong>vehicle interior temperature</strong>: a physics-based estimate of how hot the
|
||||||
|
cabin gets when parked and sealed, adjusted for your chosen vehicle type (car, MPV, SUV,
|
||||||
|
motorhome, or caravan). <strong>Wind speed</strong> is included because it matters
|
||||||
|
significantly for high-sided vehicles and caravans. <strong>Cloud cover</strong> and
|
||||||
|
<strong>air temperature</strong> give context, <strong>precipitation</strong> covers
|
||||||
|
driving conditions, and <strong>UTCI+P</strong> tells you what it will feel like when
|
||||||
|
you step outside. <strong>Soil moisture</strong> is included specifically for motorhome
|
||||||
|
and caravan users — values above 0.4 m³/m³ indicate saturated ground where pitching
|
||||||
|
or manoeuvring is likely to cause problems.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-block">
|
||||||
|
<p class="profile-name">🏠 Home</p>
|
||||||
|
<p>
|
||||||
|
For managing indoor comfort without air conditioning — the typical UK home scenario.
|
||||||
|
The two indoor temperature columns are central: <strong>Indoors</strong> estimates the
|
||||||
|
ambient temperature inside a typical brick house with windows closed and no active cooling,
|
||||||
|
accounting for wall conduction, window solar gain, and thermal mass (indoor temperatures
|
||||||
|
usually peak 2–4 hours after the outdoor peak). <strong>Managed indoors</strong> shows
|
||||||
|
what you could achieve by following standard heatwave advice — closing curtains to block
|
||||||
|
direct solar gain and opening windows whenever the outdoor air is cooler than inside.
|
||||||
|
Supporting columns include <strong>air temperature</strong>, <strong>relative humidity</strong>
|
||||||
|
and <strong>dew point</strong> (important for condensation and mould risk in cooler months),
|
||||||
|
<strong>wind speed</strong> for ventilation decisions, <strong>cloud cover</strong>,
|
||||||
|
and <strong>precipitation</strong> — so you know whether to open the windows or bring
|
||||||
|
the washing in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Column reference</h2>
|
||||||
|
<p>Every column in the SunScope forecast table is described below.</p>
|
||||||
|
|
||||||
|
<table class="stress-table col-ref-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Column</th><th>What it shows</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>Air °C</strong></td><td>Standard air temperature at 2 m above ground — the thermometer reading. Does not account for sun, wind, or humidity.</td></tr>
|
||||||
|
<tr><td><strong>RH %</strong></td><td>Relative humidity: how much moisture the air holds as a percentage of its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.</td></tr>
|
||||||
|
<tr><td><strong>Dew Point</strong></td><td>The temperature at which air becomes saturated and moisture begins to condense. Above 16 °C it starts to feel muggy; above 21 °C it feels oppressive. A better measure of absolute humidity than RH.</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>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>Sun °</strong></td><td>The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the radiation reaching the ground.</td></tr>
|
||||||
|
<tr><td><strong>Direct W/m²</strong></td><td>Shortwave solar radiation arriving as a direct beam from the sun. The primary driver of skin heating on sunny days.</td></tr>
|
||||||
|
<tr><td><strong>Diffuse W/m²</strong></td><td>Solar radiation scattered from all directions across the sky. Present even under cloud cover; contributes to the overall solar heat load.</td></tr>
|
||||||
|
<tr><td><strong>Tmrt °C</strong></td><td>Mean Radiant Temperature — the temperature your skin effectively "sees" from all surrounding surfaces and the sun combined. Can exceed air temperature by 20–30 °C on a sunny day; this is why shade feels so much cooler.</td></tr>
|
||||||
|
<tr><td><strong>UTCI−Air Δ</strong></td><td>The gap between the UTCI felt temperature and the plain air temperature. A large positive value means solar radiation is adding significant heat stress beyond what the thermometer shows.</td></tr>
|
||||||
|
<tr><td><strong>UTCI °C</strong></td><td>Universal Thermal Climate Index — the raw felt temperature combining air temp, humidity, wind, and solar radiation. Does not include precipitation effects.</td></tr>
|
||||||
|
<tr><td><strong>UV-A</strong></td><td>Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass. Present throughout daylight hours.</td></tr>
|
||||||
|
<tr><td><strong>UV-B</strong></td><td>Estimated UV-B radiation index. Causes sunburn and drives vitamin D production. Intensity falls sharply at low solar elevations due to the longer atmospheric path length.</td></tr>
|
||||||
|
<tr><td><strong>Burn time</strong></td><td>Estimated time to reach one Minimal Erythemal Dose (MED) — the threshold for sunburn — based on the UV index and your selected Fitzpatrick skin type. A guide, not a medical measurement.</td></tr>
|
||||||
|
<tr><td><strong>UTCI+P °C</strong></td><td>SunScope's adjusted felt temperature: UTCI plus the soak-factor penalty for rain and snow. Wet clothing and skin can reduce the effective felt temperature by up to 8 °C in heavy, windy rain.</td></tr>
|
||||||
|
<tr><td><strong>Precip mm</strong></td><td>Expected rainfall or snowfall in mm per hour. Even light drizzle meaningfully reduces felt temperature when combined with wind.</td></tr>
|
||||||
|
<tr><td><strong>Soil °C surface</strong></td><td>Temperature of the soil at the surface (0 cm depth). Most seeds germinate above 7–10 °C. Useful for sowing decisions and frost assessment.</td></tr>
|
||||||
|
<tr><td><strong>Soil °C 6 cm</strong></td><td>Soil temperature at 6 cm depth — the root zone for many crops and seedlings. Lags behind the surface by several hours.</td></tr>
|
||||||
|
<tr><td><strong>Soil moisture</strong></td><td>Volumetric water content of the top 1 cm of soil in m³/m³. Above 0.4 indicates saturated ground; below 0.2 is dry. Useful for assessing whether ground is workable, and for motorhome or caravan pitch suitability.</td></tr>
|
||||||
|
<tr><td><strong>Concrete °C</strong></td><td>Estimated temperature of sun-exposed urban paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 15–25 °C above air temperature on sunny days.</td></tr>
|
||||||
|
<tr><td><strong>Vehicle °C</strong></td><td>Estimated ambient cabin temperature inside a sealed, parked vehicle — adjusted for your chosen vehicle type. Values above 35 °C are dangerous for children and pets; above 45 °C potentially fatal within minutes.</td></tr>
|
||||||
|
<tr><td><strong>Indoors °C</strong></td><td>Estimated temperature inside a typical UK brick house with windows closed and no active cooling. Accounts for wall conduction, window solar gain, and thermal mass — indoor peak typically lags the outdoor peak by 2–4 hours.</td></tr>
|
||||||
|
<tr><td><strong>Managed °C</strong></td><td>Estimated indoor temperature following standard UK heatwave advice: curtains closed during the day to block solar gain, windows opened whenever outdoor air is cooler than inside. Shows the benefit of passive cooling over doing nothing.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
<h2>Urban heat: concrete surface temperature</h2>
|
<h2>Urban heat: concrete surface temperature</h2>
|
||||||
<p>
|
<p>
|
||||||
The UTCI standard was developed with a natural grass surface as its reference ground — the
|
The UTCI standard was developed with a natural grass surface as its reference ground — the
|
||||||
|
|||||||
@@ -88,6 +88,29 @@ p {
|
|||||||
strong { color: #1e1208; font-weight: 600; }
|
strong { color: #1e1208; font-weight: 600; }
|
||||||
em { color: #7a5c2a; font-style: italic; }
|
em { color: #7a5c2a; font-style: italic; }
|
||||||
|
|
||||||
|
.profile-block {
|
||||||
|
background: #fef8ea;
|
||||||
|
border: 1px solid #e0d0a8;
|
||||||
|
border-left: 4px solid #c8922a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.1rem 1.4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #1e1208;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-ref-table td:first-child {
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 160px;
|
||||||
|
color: #5a3e1a;
|
||||||
|
}
|
||||||
|
|
||||||
.faq-block {
|
.faq-block {
|
||||||
background: #fef8ea;
|
background: #fef8ea;
|
||||||
border: 1px solid #e0d0a8;
|
border: 1px solid #e0d0a8;
|
||||||
|
|||||||
+96
-49
@@ -27,6 +27,7 @@ import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcrete
|
|||||||
import {
|
import {
|
||||||
utciCategory, precipPenalty, windCompass8, uvSplit,
|
utciCategory, precipPenalty, windCompass8, uvSplit,
|
||||||
SKIN_TYPES, sunburnMinutes, burnLabel,
|
SKIN_TYPES, sunburnMinutes, burnLabel,
|
||||||
|
VEHICLE_TYPES,
|
||||||
cloudCategory, confidenceBand, moonGlyph,
|
cloudCategory, confidenceBand, moonGlyph,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon } from './components.js';
|
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon } from './components.js';
|
||||||
@@ -55,6 +56,7 @@ export function UTCIForecast() {
|
|||||||
const [searchResults, setSearchResults] = useState([]); // geocoding dropdown
|
const [searchResults, setSearchResults] = useState([]); // geocoding dropdown
|
||||||
const [searching, setSearching] = useState(false); // search-in-flight flag
|
const [searching, setSearching] = useState(false); // search-in-flight flag
|
||||||
const [selectedDay, setSelectedDay] = useState(0); // which day tab is active
|
const [selectedDay, setSelectedDay] = useState(0); // which day tab is active
|
||||||
|
const [now, setNow] = useState(new Date()); // ticks every 5 min
|
||||||
const [proPromptDay, setProPromptDay] = useState(null); // locked day clicked → show upsell card
|
const [proPromptDay, setProPromptDay] = useState(null); // locked day clicked → show upsell card
|
||||||
const [proPromptSource, setProPromptSource] = useState('day'); // 'day' | 'custom'
|
const [proPromptSource, setProPromptSource] = useState('day'); // 'day' | 'custom'
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ export function UTCIForecast() {
|
|||||||
// FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE.
|
// FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE.
|
||||||
// When this is wired to real billing/auth, replace `useState(false)`
|
// When this is wired to real billing/auth, replace `useState(false)`
|
||||||
// with a check against the logged-in user.
|
// with a check against the logged-in user.
|
||||||
const [isPro, setIsPro] = useState(false);
|
const [isPro, setIsPro] = useState(true);
|
||||||
|
|
||||||
// How many days the free tier shows. Days beyond this get a 🔒.
|
// How many days the free tier shows. Days beyond this get a 🔒.
|
||||||
// Bump this number if you want to give free users more access.
|
// Bump this number if you want to give free users more access.
|
||||||
@@ -125,30 +127,30 @@ export function UTCIForecast() {
|
|||||||
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 },
|
||||||
},
|
},
|
||||||
|
home: {
|
||||||
|
label: 'Home',
|
||||||
|
icon: '🏠',
|
||||||
|
cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true },
|
||||||
|
},
|
||||||
urban: {
|
urban: {
|
||||||
label: 'Urban',
|
label: 'Urban',
|
||||||
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: true, uvB: true, 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 },
|
||||||
},
|
|
||||||
farming: {
|
|
||||||
label: 'Farming',
|
|
||||||
icon: '🌾',
|
|
||||||
cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: true, diffuse: true, 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 },
|
|
||||||
},
|
|
||||||
sailing: {
|
|
||||||
label: 'Sailing',
|
|
||||||
icon: '⛵',
|
|
||||||
cols: { hour: true, air: false, 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 },
|
|
||||||
},
|
},
|
||||||
vehicle: {
|
vehicle: {
|
||||||
label: 'Vehicle',
|
label: 'Vehicle',
|
||||||
icon: '🚗',
|
icon: '🚗',
|
||||||
cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: false, precip: false, soilT: false, soilT6: false, soilM: false, 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 },
|
||||||
},
|
},
|
||||||
home: {
|
sailing: {
|
||||||
label: 'Home',
|
label: 'Sailing',
|
||||||
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: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: 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 },
|
||||||
|
},
|
||||||
|
farming: {
|
||||||
|
label: 'Farming',
|
||||||
|
icon: '🌾',
|
||||||
|
cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false },
|
||||||
},
|
},
|
||||||
alltemps: {
|
alltemps: {
|
||||||
label: 'All Temps',
|
label: 'All Temps',
|
||||||
@@ -168,19 +170,14 @@ 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({
|
const [visibleCols, setVisibleCols] = useState({ ...FILTER_PROFILES.basic.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,
|
|
||||||
});
|
|
||||||
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.
|
||||||
const [skinType, setSkinType] = useState('II');
|
const [skinType, setSkinType] = useState('II');
|
||||||
|
|
||||||
|
// Vehicle type for the cabin heat column. 'car' is the default preset.
|
||||||
|
const [vehicleType, setVehicleType] = useState('car');
|
||||||
const searchTimeout = useRef(null);
|
const searchTimeout = useRef(null);
|
||||||
|
|
||||||
// Refs for the two-scroller table layout (sticky-to-viewport header +
|
// Refs for the two-scroller table layout (sticky-to-viewport header +
|
||||||
@@ -224,7 +221,7 @@ export function UTCIForecast() {
|
|||||||
soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.' },
|
soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.' },
|
||||||
soilM: { title: 'Soil Moisture', desc: 'Volumetric water content of the top 1 cm of soil (m³/m³). Values above 0.4 suggest saturated ground; below 0.2 indicates dry conditions.' },
|
soilM: { title: 'Soil Moisture', desc: 'Volumetric water content of the top 1 cm of soil (m³/m³). Values above 0.4 suggest saturated ground; below 0.2 indicates dry conditions.' },
|
||||||
concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 15–25 °C above air temperature on sunny days.' },
|
concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 15–25 °C above air temperature on sunny days.' },
|
||||||
vehicleT: { title: 'Vehicle Interior', desc: 'Estimated peak ambient cabin temperature inside a sealed, parked vehicle. Modelled from solar gain through body panels and side windows. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' },
|
vehicleT: { title: 'Vehicle Interior', desc: 'Estimated peak ambient cabin temperature inside a sealed, parked vehicle. Adjusted for your selected vehicle type — albedo, glazing area, and insulation all affect the result. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' },
|
||||||
indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a typical UK brick house with windows closed and no air conditioning. Accounts for wall conduction, window solar gain, and thermal mass — indoor temperatures typically peak 2–4 hours after the outdoor peak.' },
|
indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a typical UK brick house with windows closed and no air conditioning. Accounts for wall conduction, window solar gain, and thermal mass — indoor temperatures typically peak 2–4 hours after the outdoor peak.' },
|
||||||
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.' },
|
||||||
};
|
};
|
||||||
@@ -455,7 +452,7 @@ export function UTCIForecast() {
|
|||||||
if (ro) ro.disconnect();
|
if (ro) ro.disconnect();
|
||||||
window.removeEventListener('resize', sync);
|
window.removeEventListener('resize', sync);
|
||||||
};
|
};
|
||||||
}, [forecast, visibleCols, selectedDay, skinType]);
|
}, [forecast, visibleCols, selectedDay, skinType, vehicleType]);
|
||||||
|
|
||||||
// Geocoding search
|
// Geocoding search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -503,6 +500,37 @@ export function UTCIForecast() {
|
|||||||
load();
|
load();
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
|
// ─── AUTO-REFRESH — tick every 5 minutes ─────────────────────────────
|
||||||
|
// Updates `now` so the scope always shows the current hour's data.
|
||||||
|
// Also refetches the forecast so fresh API data comes in automatically.
|
||||||
|
useEffect(() => {
|
||||||
|
const FIVE_MIN = 5 * 60 * 1000;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setNow(new Date());
|
||||||
|
// Trigger a fresh forecast fetch by nudging location identity.
|
||||||
|
// We do this via a separate load rather than touching location state
|
||||||
|
// (which would reset other things), so we call load() directly.
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const url =
|
||||||
|
`https://api.open-meteo.com/v1/forecast` +
|
||||||
|
`?latitude=${location.lat}&longitude=${location.lon}` +
|
||||||
|
`&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` +
|
||||||
|
`wind_speed_10m,wind_direction_10m,wind_gusts_10m,` +
|
||||||
|
`direct_radiation,diffuse_radiation,shortwave_radiation,` +
|
||||||
|
`cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` +
|
||||||
|
`uv_index,precipitation,snowfall,` +
|
||||||
|
`soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
|
||||||
|
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (r.ok) setForecast(await r.json());
|
||||||
|
} catch (_) { /* silently ignore refresh errors */ }
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}, FIVE_MIN);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
// ─── COMPUTATION ─────────────────────────────────────────────────────
|
// ─── COMPUTATION ─────────────────────────────────────────────────────
|
||||||
// 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
|
||||||
@@ -551,7 +579,7 @@ export function UTCIForecast() {
|
|||||||
// dt kept for SkyScope / backward compat — same as dtUTC.
|
// dt kept for SkyScope / backward compat — same as dtUTC.
|
||||||
const dt = dtUTC;
|
const dt = dtUTC;
|
||||||
const elev = solarElevationDeg(location.lat, location.lon, dtUTC);
|
const elev = solarElevationDeg(location.lat, location.lon, dtUTC);
|
||||||
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev);
|
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType);
|
||||||
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);
|
||||||
@@ -592,7 +620,6 @@ export function UTCIForecast() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const visible = days[selectedDay]?.rows || [];
|
const visible = days[selectedDay]?.rows || [];
|
||||||
const now = new Date();
|
|
||||||
// nowLocalISO: current moment in location-local time as "YYYY-MM-DDTHH"
|
// nowLocalISO: current moment in location-local time as "YYYY-MM-DDTHH"
|
||||||
// Used to match against r.iso (which is already a local wall-clock string).
|
// Used to match against r.iso (which is already a local wall-clock string).
|
||||||
const nowLocalISO = new Date(now.getTime() + utcOffsetMs)
|
const nowLocalISO = new Date(now.getTime() + utcOffsetMs)
|
||||||
@@ -653,6 +680,7 @@ export function UTCIForecast() {
|
|||||||
loading=${loading}
|
loading=${loading}
|
||||||
elev=${currentRow?.elev ?? 0}
|
elev=${currentRow?.elev ?? 0}
|
||||||
dt=${currentRow?.dt ?? new Date()}
|
dt=${currentRow?.dt ?? new Date()}
|
||||||
|
glob=${currentRow?.glob ?? 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -938,6 +966,43 @@ export function UTCIForecast() {
|
|||||||
COLUMN TOGGLES — Pro only. Hidden entirely for free users
|
COLUMN TOGGLES — Pro only. Hidden entirely for free users
|
||||||
(the profile selector above provides enough control for them).
|
(the profile selector above provides enough control for them).
|
||||||
-->
|
-->
|
||||||
|
${(FILTER_PROFILES[activeProfile]?.cols['burn'] || FILTER_PROFILES[activeProfile]?.cols['vehicleT']) && html`
|
||||||
|
<div class="col-toggles">
|
||||||
|
<span class="col-toggles-label">Columns:</span>
|
||||||
|
${FILTER_PROFILES[activeProfile]?.cols['burn'] && html`
|
||||||
|
<select
|
||||||
|
class=${`col-toggle burn-select${visibleCols.burn ? ' on' : ''}`}
|
||||||
|
value=${visibleCols.burn ? skinType : 'off'}
|
||||||
|
onChange=${(e) => {
|
||||||
|
if (e.target.value === 'off') {
|
||||||
|
setVisibleCols(prev => ({ ...prev, burn: false }));
|
||||||
|
} else {
|
||||||
|
setSkinType(e.target.value);
|
||||||
|
setVisibleCols(prev => ({ ...prev, burn: true }));
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<option value="off">${visibleCols.burn ? 'Hide Burn' : 'Burn'}</option>
|
||||||
|
${Object.entries(SKIN_TYPES).map(([k, v]) => html`
|
||||||
|
<option key=${k} value=${k}>${v.name.split(' · ')[1]} skin</option>`)}
|
||||||
|
</select>`}
|
||||||
|
${FILTER_PROFILES[activeProfile]?.cols['vehicleT'] && html`
|
||||||
|
<select
|
||||||
|
class=${`col-toggle burn-select${visibleCols.vehicleT ? ' on' : ''}`}
|
||||||
|
value=${visibleCols.vehicleT ? vehicleType : 'off'}
|
||||||
|
onChange=${(e) => {
|
||||||
|
if (e.target.value === 'off') {
|
||||||
|
setVisibleCols(prev => ({ ...prev, vehicleT: false }));
|
||||||
|
} else {
|
||||||
|
setVehicleType(e.target.value);
|
||||||
|
setVisibleCols(prev => ({ ...prev, vehicleT: true }));
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<option value="off">${visibleCols.vehicleT ? 'Hide Vehicle' : 'Vehicle'}</option>
|
||||||
|
${Object.entries(VEHICLE_TYPES).map(([k, v]) => html`
|
||||||
|
<option key=${k} value=${k}>${v.name}</option>`)}
|
||||||
|
</select>`}
|
||||||
|
</div>`}
|
||||||
|
|
||||||
${isPro && html`
|
${isPro && html`
|
||||||
<div class="col-toggles">
|
<div class="col-toggles">
|
||||||
<span class="col-toggles-label">Columns:</span>
|
<span class="col-toggles-label">Columns:</span>
|
||||||
@@ -950,7 +1015,6 @@ export function UTCIForecast() {
|
|||||||
{ key: 'soilT6', label: 'Soil 6cm' },
|
{ key: 'soilT6', label: 'Soil 6cm' },
|
||||||
{ key: 'soilM', label: 'Soil moist' },
|
{ key: 'soilM', label: 'Soil moist' },
|
||||||
{ key: 'concreteT', label: 'Concrete' },
|
{ key: 'concreteT', label: 'Concrete' },
|
||||||
{ key: 'vehicleT', label: 'Vehicle' },
|
|
||||||
{ key: 'indoorT', label: 'Indoors' },
|
{ key: 'indoorT', label: 'Indoors' },
|
||||||
{ key: 'managedT', label: 'Managed' },
|
{ key: 'managedT', label: 'Managed' },
|
||||||
{ key: 'wind', label: 'Wind' },
|
{ key: 'wind', label: 'Wind' },
|
||||||
@@ -973,23 +1037,6 @@ export function UTCIForecast() {
|
|||||||
onClick=${() => toggleCol(c.key)}
|
onClick=${() => toggleCol(c.key)}
|
||||||
>${c.label}</button>`)}
|
>${c.label}</button>`)}
|
||||||
|
|
||||||
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['burn']) && html`
|
|
||||||
<select
|
|
||||||
class=${`col-toggle burn-select${visibleCols.burn ? ' on' : ''}`}
|
|
||||||
value=${visibleCols.burn ? skinType : 'off'}
|
|
||||||
onChange=${(e) => {
|
|
||||||
if (e.target.value === 'off') {
|
|
||||||
setVisibleCols(prev => ({ ...prev, burn: false }));
|
|
||||||
} else {
|
|
||||||
setSkinType(e.target.value);
|
|
||||||
setVisibleCols(prev => ({ ...prev, burn: true }));
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<option value="off">${visibleCols.burn ? 'Hide Burn' : 'Burn'}</option>
|
|
||||||
${Object.entries(SKIN_TYPES).map(([k, v]) => html`
|
|
||||||
<option key=${k} value=${k}>${v.name.split(' · ')[1]} skin</option>`)}
|
|
||||||
</select>`}
|
|
||||||
|
|
||||||
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['precip']) && html`
|
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['precip']) && html`
|
||||||
<button
|
<button
|
||||||
class=${`col-toggle${visibleCols.precip ? ' on' : ''}`}
|
class=${`col-toggle${visibleCols.precip ? ' on' : ''}`}
|
||||||
@@ -1079,7 +1126,7 @@ export function UTCIForecast() {
|
|||||||
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
|
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
|
||||||
<td class="utci-time">
|
<td class="utci-time">
|
||||||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
|
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
|
||||||
<${SkyScope} elev=${r.elev} dt=${r.dt} size=${30} />
|
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${30} />
|
||||||
<span>${localHHMM}</span>
|
<span>${localHHMM}</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
+36
-16
@@ -39,7 +39,7 @@ const html = htm.bind(h);
|
|||||||
// • sunR — the sun's drawn radius
|
// • sunR — the sun's drawn radius
|
||||||
// • the stroke colour #c8922a is the brass — change for a different metal
|
// • the stroke colour #c8922a is the brass — change for a different metal
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
export function SkyScope({ elev, dt, size = 26 }) {
|
export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
|
||||||
const r = size / 2;
|
const r = size / 2;
|
||||||
const innerR = r - 1.2;
|
const innerR = r - 1.2;
|
||||||
|
|
||||||
@@ -60,10 +60,13 @@ export function SkyScope({ elev, dt, size = 26 }) {
|
|||||||
const sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5);
|
const sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5);
|
||||||
const sunR = Math.max(2.2, innerR * 0.32);
|
const sunR = Math.max(2.2, innerR * 0.32);
|
||||||
|
|
||||||
// Near-horizon sun gets a warm glow halo
|
// Radial glow: strength driven by glob (W/m²). Uses a radialGradient
|
||||||
const sunGlowOpacity = elev < 10 ? Math.max(0, 1 - elev / 10) * 0.65 : 0.25;
|
// so it's intense at the sun centre and fades smoothly to transparent.
|
||||||
const sunGlowR = sunR + (elev < 6 ? 3.5 : 1.8);
|
const radFrac = Math.min(1, (glob || 0) / 900); // 0 (dark) → 1 (blazing)
|
||||||
const sunGlowColor = isRising ? '#ff8c3a' : '#ff9a6a';
|
const horizFrac = Math.max(0, 1 - elev / 20); // extra warmth near horizon
|
||||||
|
const glowColor = isRising ? '#ffb040' : '#ffc060';
|
||||||
|
const glowR = sunR + 4 + radFrac * 6; // total glow radius
|
||||||
|
const glowPeak = 0.35 + radFrac * 0.45 + horizFrac * 0.15; // centre opacity
|
||||||
|
|
||||||
const phase = moonPhaseFraction(dt);
|
const phase = moonPhaseFraction(dt);
|
||||||
const moonR = innerR * 0.55;
|
const moonR = innerR * 0.55;
|
||||||
@@ -94,6 +97,14 @@ export function SkyScope({ elev, dt, size = 26 }) {
|
|||||||
<stop offset="0%" stop-color=${grass.top} />
|
<stop offset="0%" stop-color=${grass.top} />
|
||||||
<stop offset="100%" stop-color=${grass.bot} />
|
<stop offset="100%" stop-color=${grass.bot} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<!-- Sun glow — same colour as sun disk, fades from opaque at centre to transparent -->
|
||||||
|
<radialGradient id=${`glow-${uid}`} gradientUnits="userSpaceOnUse"
|
||||||
|
cx=${r} cy=${sunY} r=${glowR}>
|
||||||
|
<stop offset="0%" stop-color="#fff8c0" stop-opacity="1" />
|
||||||
|
<stop offset=${`${Math.round(sunR / glowR * 100)}%`} stop-color="#fff8c0" stop-opacity="1" />
|
||||||
|
<stop offset="70%" stop-color="#ffe060" stop-opacity=${glowPeak * 0.5} />
|
||||||
|
<stop offset="100%" stop-color="#ffcc30" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
<!-- Moon phase mask: lit area = white, shadow = black punched out -->
|
<!-- Moon phase mask: lit area = white, shadow = black punched out -->
|
||||||
<mask id=${`moon-mask-${uid}`}>
|
<mask id=${`moon-mask-${uid}`}>
|
||||||
<circle cx=${r} cy=${r} r=${moonR} fill="white" />
|
<circle cx=${r} cy=${r} r=${moonR} fill="white" />
|
||||||
@@ -111,10 +122,8 @@ export function SkyScope({ elev, dt, size = 26 }) {
|
|||||||
|
|
||||||
${isDay && html`
|
${isDay && html`
|
||||||
<g clip-path=${`url(#${clipId})`}>
|
<g clip-path=${`url(#${clipId})`}>
|
||||||
<!-- Warm glow halo — bigger and more vivid near the horizon -->
|
<!-- Glow + sun disk as one seamless radial gradient circle -->
|
||||||
<circle cx=${r} cy=${sunY} r=${sunGlowR} fill=${sunGlowColor} opacity=${sunGlowOpacity} />
|
<circle cx=${r} cy=${sunY} r=${glowR} fill=${`url(#glow-${uid})`} />
|
||||||
<!-- Sun disk -->
|
|
||||||
<circle cx=${r} cy=${sunY} r=${sunR} fill="#fff8c0" stroke="#e6a32a" stroke-width="0.5" />
|
|
||||||
</g>`}
|
</g>`}
|
||||||
|
|
||||||
${(isTwil || isNight) && html`
|
${(isTwil || isNight) && html`
|
||||||
@@ -294,7 +303,7 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
|
|||||||
// • (value + 10) / 60 maps UTCI -10..50 onto the 270° sweep —
|
// • (value + 10) / 60 maps UTCI -10..50 onto the 270° sweep —
|
||||||
// widen the dial range by changing those numbers
|
// widen the dial range by changing those numbers
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) {
|
export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), glob = 0 }) {
|
||||||
// ── Day/night scene behind the readout ─────────────────────────────
|
// ── Day/night scene behind the readout ─────────────────────────────
|
||||||
// The whole back of the lens is a SkyScope-style scene: sky on top,
|
// The whole back of the lens is a SkyScope-style scene: sky on top,
|
||||||
// grass on the bottom, sun positioned by elevation (or moon at night
|
// grass on the bottom, sun positioned by elevation (or moon at night
|
||||||
@@ -312,10 +321,13 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() })
|
|||||||
const elevClamped = Math.max(0, Math.min(90, elev)); // clamp to above horizon
|
const elevClamped = Math.max(0, Math.min(90, elev)); // clamp to above horizon
|
||||||
const sunY = cy - Math.sin((elevClamped * Math.PI) / 180) * (lensR - 18);
|
const sunY = cy - Math.sin((elevClamped * Math.PI) / 180) * (lensR - 18);
|
||||||
const sunDrawR = 14;
|
const sunDrawR = 14;
|
||||||
// Near-horizon glow for the big dial
|
// Radial glow for the big dial — sun is always at least faintly glowing
|
||||||
const sunGlowR = sunDrawR + (elev < 10 ? 7 : 3);
|
// when above the horizon; scales up with radiation strength.
|
||||||
const sunGlowOp = elev < 10 ? Math.max(0, 1 - elev / 10) * 0.55 : 0.2;
|
const radFrac = Math.min(1, (glob || 0) / 900);
|
||||||
const sunGlowCol = isRising ? '#ff8c3a' : '#ff9a6a';
|
const horizFrac = Math.max(0, 1 - elev / 20);
|
||||||
|
const sunGlowCol = isRising ? '#ffb040' : '#ffc060';
|
||||||
|
const sunGlowR = sunDrawR + 20 + radFrac * 30; // total glow radius
|
||||||
|
const sunGlowPeak = 0.30 + radFrac * 0.45 + horizFrac * 0.15; // centre opacity
|
||||||
const moonDrawR = 15;
|
const moonDrawR = 15;
|
||||||
const moonY = cy - 45;
|
const moonY = cy - 45;
|
||||||
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonDrawR;
|
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonDrawR;
|
||||||
@@ -389,6 +401,14 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() })
|
|||||||
<stop offset="0%" stop-color=${grass.top} />
|
<stop offset="0%" stop-color=${grass.top} />
|
||||||
<stop offset="100%" stop-color=${grass.bot} />
|
<stop offset="100%" stop-color=${grass.bot} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<!-- Sun glow — same colour as sun disk, fades from opaque at centre to transparent -->
|
||||||
|
<radialGradient id="scope-sun-glow" gradientUnits="userSpaceOnUse"
|
||||||
|
cx=${cx} cy=${sunY} r=${sunGlowR}>
|
||||||
|
<stop offset="0%" stop-color="#fff8c0" stop-opacity="1" />
|
||||||
|
<stop offset=${`${Math.round(sunDrawR / sunGlowR * 100)}%`} stop-color="#fff8c0" stop-opacity="1" />
|
||||||
|
<stop offset="70%" stop-color="#ffe060" stop-opacity=${sunGlowPeak * 0.5} />
|
||||||
|
<stop offset="100%" stop-color="#ffcc30" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
<!-- Moon phase mask: white = lit area, black = shadow punched out -->
|
<!-- Moon phase mask: white = lit area, black = shadow punched out -->
|
||||||
<mask id="scope-moon-mask">
|
<mask id="scope-moon-mask">
|
||||||
<circle cx=${cx} cy=${moonY} r=${moonDrawR} fill="white" />
|
<circle cx=${cx} cy=${moonY} r=${moonDrawR} fill="white" />
|
||||||
@@ -403,8 +423,8 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() })
|
|||||||
<path d=${`M ${cx - lensR} ${cy} A ${lensR} ${lensR} 0 0 0 ${cx + lensR} ${cy} Z`}
|
<path d=${`M ${cx - lensR} ${cy} A ${lensR} ${lensR} 0 0 0 ${cx + lensR} ${cy} Z`}
|
||||||
fill="url(#scope-lens-grass)" />
|
fill="url(#scope-lens-grass)" />
|
||||||
${isDay && html`<${Fragment}>
|
${isDay && html`<${Fragment}>
|
||||||
<circle cx=${cx} cy=${sunY} r=${sunGlowR} fill=${sunGlowCol} opacity=${sunGlowOp} />
|
<!-- Glow + sun disk as one seamless radial gradient circle -->
|
||||||
<circle cx=${cx} cy=${sunY} r=${sunDrawR} fill="#fff8c0" stroke="#e6a32a" stroke-width="0.8" />
|
<circle cx=${cx} cy=${sunY} r=${sunGlowR} fill="url(#scope-sun-glow)" />
|
||||||
</>`}
|
</>`}
|
||||||
${(isTwil || (!isDay && !isTwil)) && html`<${Fragment}>
|
${(isTwil || (!isDay && !isTwil)) && html`<${Fragment}>
|
||||||
<!-- Lit crescent — full moon disk clipped by the phase mask -->
|
<!-- Lit crescent — full moon disk clipped by the phase mask -->
|
||||||
|
|||||||
+12
-8
@@ -7,11 +7,14 @@
|
|||||||
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position
|
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position
|
||||||
// calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) Mean radiant temp
|
// calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) Mean radiant temp
|
||||||
// utciApprox(Ta, Tmrt, va10, ehPa) Bröde et al. 2012 polynomial
|
// utciApprox(Ta, Tmrt, va10, ehPa) Bröde et al. 2012 polynomial
|
||||||
|
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType)
|
||||||
//
|
//
|
||||||
// Nothing in here should need editing unless the underlying science
|
// Nothing in here should need editing unless the underlying science
|
||||||
// changes. All numbers are peer-reviewed constants or coefficients.
|
// changes. All numbers are peer-reviewed constants or coefficients.
|
||||||
// ════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { VEHICLE_TYPES } from './utils.js';
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// PHYSICAL CONSTANTS
|
// PHYSICAL CONSTANTS
|
||||||
// ───────────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────────
|
||||||
@@ -262,12 +265,14 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr) {
|
|||||||
// 35–45 °C — dangerous for children/pets (hyperthermia risk)
|
// 35–45 °C — dangerous for children/pets (hyperthermia risk)
|
||||||
// > 45 °C — potentially fatal within minutes
|
// > 45 °C — potentially fatal within minutes
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
export function calcVehicleInteriorTemp(Ta, globalRad, solElev) {
|
export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'car') {
|
||||||
if (globalRad == null || Ta == null) return null;
|
if (globalRad == null || Ta == null) return null;
|
||||||
|
|
||||||
|
// Look up vehicle preset; fall back to a standard car if key unknown.
|
||||||
|
const preset = VEHICLE_TYPES[vehicleType] || VEHICLE_TYPES.car;
|
||||||
|
|
||||||
// ── 1. Panel conduction ──────────────────────────────────────────
|
// ── 1. Panel conduction ──────────────────────────────────────────
|
||||||
const albedoPanel = 0.25; // typical mixed fleet
|
const panelAbsorbed = globalRad * (1 - preset.albedo); // W/m² absorbed by bodywork
|
||||||
const panelAbsorbed = globalRad * (1 - albedoPanel); // W/m² absorbed by bodywork
|
|
||||||
// Panel surface temp: absorbed solar / convective loss to outside air
|
// Panel surface temp: absorbed solar / convective loss to outside air
|
||||||
// hOut ~10 W/m²K (light breeze over panel surface even when parked)
|
// hOut ~10 W/m²K (light breeze over panel surface even when parked)
|
||||||
const hOut = 10;
|
const hOut = 10;
|
||||||
@@ -277,8 +282,8 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev) {
|
|||||||
const conductionGain = hCabin * (panelSurfaceTemp - Ta); // W/m²
|
const conductionGain = hCabin * (panelSurfaceTemp - Ta); // W/m²
|
||||||
|
|
||||||
// ── 2. Side-window glazing gain (angle-dependent) ─────────────────
|
// ── 2. Side-window glazing gain (angle-dependent) ─────────────────
|
||||||
// Glazing transmission for auto glass ~0.70
|
// Glazing transmission for auto glass ~0.70; scaled by vehicle glazing area.
|
||||||
const tau = 0.70;
|
const tau = 0.70 * preset.glazingArea;
|
||||||
let glazingGain = 0;
|
let glazingGain = 0;
|
||||||
if (solElev != null && solElev > 10 && solElev < 60) {
|
if (solElev != null && solElev > 10 && solElev < 60) {
|
||||||
// Scale factor: peaks around 30–40° elevation (sun cuts squarely
|
// Scale factor: peaks around 30–40° elevation (sun cuts squarely
|
||||||
@@ -298,9 +303,8 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev) {
|
|||||||
// ── Combine into cabin air temperature ───────────────────────────
|
// ── Combine into cabin air temperature ───────────────────────────
|
||||||
// Total heat input per m² of cabin surface
|
// Total heat input per m² of cabin surface
|
||||||
const totalGain = conductionGain + glazingGain;
|
const totalGain = conductionGain + glazingGain;
|
||||||
// Cabin heat loss: poor natural ventilation in sealed car ~2.0 W/m²K
|
// Cabin heat loss: varies by vehicle type (motorhomes insulated, cars not)
|
||||||
const hCabinLoss = 2.0;
|
const Ti = Ta + totalGain / preset.hCabinLoss;
|
||||||
const Ti = Ta + totalGain / hCabinLoss;
|
|
||||||
|
|
||||||
// Clamp: can't be cooler than outside air; physical cap at 90 °C
|
// Clamp: can't be cooler than outside air; physical cap at 90 °C
|
||||||
return Math.max(Ta, Math.min(Ti, 90));
|
return Math.max(Ta, Math.min(Ti, 90));
|
||||||
|
|||||||
+77
-35
@@ -9,6 +9,7 @@
|
|||||||
// SKIN_TYPES Fitzpatrick skin type table
|
// SKIN_TYPES Fitzpatrick skin type table
|
||||||
// sunburnMinutes(uv, skinType) minutes to MED
|
// sunburnMinutes(uv, skinType) minutes to MED
|
||||||
// burnLabel(mins) formats burn time as "12m"/"1.5h"
|
// burnLabel(mins) formats burn time as "12m"/"1.5h"
|
||||||
|
// VEHICLE_TYPES vehicle presets for cabin heat model
|
||||||
// cloudCategory(total,low,mid,high) → 'clear'|'wispy'|'scattered'|'overcast'
|
// cloudCategory(total,low,mid,high) → 'clear'|'wispy'|'scattered'|'overcast'
|
||||||
// confidenceBand(i) day-tab gradient + label
|
// confidenceBand(i) day-tab gradient + label
|
||||||
// moonPhaseFraction(date) 0..1 synodic phase
|
// moonPhaseFraction(date) 0..1 synodic phase
|
||||||
@@ -122,6 +123,23 @@ export function burnLabel(mins) {
|
|||||||
return `${Math.round(mins)}m`;
|
return `${Math.round(mins)}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// VEHICLE TYPES — presets for the cabin heat model.
|
||||||
|
// ───────────────────────────────────────────────────────────────────
|
||||||
|
// Each entry tweaks the three physical levers in calcVehicleInteriorTemp:
|
||||||
|
// albedo — how much solar the bodywork reflects (0 = black, 1 = mirror)
|
||||||
|
// glazingArea — relative side-window glass area (1.0 = typical car)
|
||||||
|
// hCabinLoss — how easily heat escapes the cabin (W/m²K); lower = hotter
|
||||||
|
// Motorhomes have thicker insulation → lower loss
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
export const VEHICLE_TYPES = {
|
||||||
|
car: { name: 'Car / Hatchback', albedo: 0.25, glazingArea: 1.0, hCabinLoss: 2.0 },
|
||||||
|
mpv: { name: 'MPV / People Carrier', albedo: 0.25, glazingArea: 1.3, hCabinLoss: 2.0 },
|
||||||
|
suv: { name: 'SUV / 4×4', albedo: 0.22, glazingArea: 1.1, hCabinLoss: 1.9 },
|
||||||
|
motorhome: { name: 'Motorhome / Campervan', albedo: 0.35, glazingArea: 0.6, hCabinLoss: 1.2 },
|
||||||
|
caravan: { name: 'Caravan (towed)', albedo: 0.40, glazingArea: 0.4, hCabinLoss: 1.0 },
|
||||||
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// CLOUD CATEGORY — pick one of 4 icon styles from low/mid/high split.
|
// CLOUD CATEGORY — pick one of 4 icon styles from low/mid/high split.
|
||||||
// ───────────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────────
|
||||||
@@ -220,49 +238,73 @@ export function moonGlyph(p) {
|
|||||||
// Sunset palette → pinks, purples, mauves at the horizon.
|
// Sunset palette → pinks, purples, mauves at the horizon.
|
||||||
// Both share the same deep blue zenith at high elevations.
|
// Both share the same deep blue zenith at high elevations.
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
export function skyGradientForElev(e, isRising) {
|
// Sky colour keyframes — [elevation, topHex, botHex] for rising and setting sun.
|
||||||
// Full daytime — blue sky, paler toward horizon
|
// Colours are smoothly interpolated between adjacent keyframes.
|
||||||
if (e > 30) return { top: '#5bb8e8', bot: '#9fd3ef' };
|
const SKY_KEYS_RISING = [
|
||||||
if (e > 10) return { top: '#7cc5ec', bot: '#c8e3ee' };
|
[-18, '#0e0c28', '#1a1538'], // deep night
|
||||||
|
[-14, '#1e1a50', '#3a2f60'], // nautical twilight
|
||||||
|
[ -8, '#2a3a6a', '#a04828'], // pre-dawn: indigo → burnt sienna
|
||||||
|
[ -3, '#4a7aaa', '#e8622a'], // horizon band: deep blue → vivid red-orange
|
||||||
|
[ 3, '#6aaddb', '#ffb347'], // golden hour: blue → warm amber
|
||||||
|
[ 10, '#7cc5ec', '#c8e3ee'], // low sun: pale blue sky
|
||||||
|
[ 30, '#5bb8e8', '#9fd3ef'], // mid-day blue
|
||||||
|
[ 60, '#2e8fd4', '#7cc8ef'], // high noon: rich deep blue
|
||||||
|
[ 90, '#1a7abf', '#60b8e8'], // zenith (sun directly overhead)
|
||||||
|
];
|
||||||
|
const SKY_KEYS_SETTING = [
|
||||||
|
[-18, '#0e0c28', '#1a1538'],
|
||||||
|
[-14, '#1e1a50', '#3a2f60'],
|
||||||
|
[ -8, '#5a3572', '#c07080'], // dusk: purple → dusty rose/mauve
|
||||||
|
[ -3, '#7a5090', '#e8826a'], // horizon band: violet → coral/pink
|
||||||
|
[ 3, '#7b8fc4', '#ffb877'], // golden hour: blue-violet → golden
|
||||||
|
[ 10, '#7cc5ec', '#c8e3ee'],
|
||||||
|
[ 30, '#5bb8e8', '#9fd3ef'],
|
||||||
|
[ 60, '#2e8fd4', '#7cc8ef'],
|
||||||
|
[ 90, '#1a7abf', '#60b8e8'],
|
||||||
|
];
|
||||||
|
|
||||||
// Low sun — golden hour / near-horizon
|
function hexToRgb(hex) {
|
||||||
if (e > 3) {
|
const n = parseInt(hex.slice(1), 16);
|
||||||
return isRising
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
? { top: '#6aaddb', bot: '#ffb347' } // sunrise: blue → warm amber/orange
|
}
|
||||||
: { top: '#7b8fc4', bot: '#ffb877' }; // sunset: blue-violet → golden
|
function rgbToHex([r, g, b]) {
|
||||||
}
|
return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
// Sun at/just below horizon — the dramatic band
|
function lerpRgb(a, b, t) {
|
||||||
if (e > -3) {
|
return a.map((v, i) => v + t * (b[i] - v));
|
||||||
return isRising
|
|
||||||
? { top: '#4a7aaa', bot: '#e8622a' } // sunrise: deep blue → vivid red-orange
|
|
||||||
: { top: '#7a5090', bot: '#e8826a' }; // sunset: violet → coral/pink
|
|
||||||
}
|
|
||||||
|
|
||||||
// Civil twilight
|
|
||||||
if (e > -8) {
|
|
||||||
return isRising
|
|
||||||
? { top: '#2a3a6a', bot: '#a04828' } // pre-dawn: indigo → burnt sienna
|
|
||||||
: { top: '#5a3572', bot: '#c07080' }; // dusk: purple → dusty rose/mauve
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nautical twilight
|
|
||||||
if (e > -14) return { top: '#1e1a50', bot: '#3a2f60' };
|
|
||||||
|
|
||||||
// Night
|
|
||||||
return { top: '#0e0c28', bot: '#1a1538' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience flat colour — returns the bot (horizon) colour for contexts
|
function interpolateSkyKeys(keys, e) {
|
||||||
// that still need a single fill (e.g. moon shadow fill).
|
// Clamp to key range
|
||||||
|
if (e <= keys[0][0]) return { top: keys[0][1], bot: keys[0][2] };
|
||||||
|
if (e >= keys[keys.length - 1][0]) {
|
||||||
|
const k = keys[keys.length - 1];
|
||||||
|
return { top: k[1], bot: k[2] };
|
||||||
|
}
|
||||||
|
// Find bracketing keyframes
|
||||||
|
let lo = keys[0], hi = keys[1];
|
||||||
|
for (let i = 1; i < keys.length; i++) {
|
||||||
|
if (keys[i][0] >= e) { lo = keys[i - 1]; hi = keys[i]; break; }
|
||||||
|
}
|
||||||
|
const t = (e - lo[0]) / (hi[0] - lo[0]);
|
||||||
|
const top = rgbToHex(lerpRgb(hexToRgb(lo[1]), hexToRgb(hi[1]), t));
|
||||||
|
const bot = rgbToHex(lerpRgb(hexToRgb(lo[2]), hexToRgb(hi[2]), t));
|
||||||
|
return { top, bot };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skyGradientForElev(e, isRising) {
|
||||||
|
return interpolateSkyKeys(isRising ? SKY_KEYS_RISING : SKY_KEYS_SETTING, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience flat colour for contexts needing a single fill.
|
||||||
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 skyFillForElev but for the ground.
|
// Grass palette — mirrors sky but for the ground strip.
|
||||||
// `top` = grass blade tips (catches sky light), `bot` = soil shadow.
|
|
||||||
export function grassFillForElev(e) {
|
export function grassFillForElev(e) {
|
||||||
if (e > 30) return { top: '#86c44a', bot: '#558a2e' }; // bright noon grass
|
if (e > 60) return { top: '#92cc50', bot: '#5e9630' }; // blazing noon
|
||||||
|
if (e > 30) return { top: '#86c44a', bot: '#558a2e' }; // bright midday
|
||||||
if (e > 10) return { top: '#7ab846', bot: '#4d802c' }; // mid-day
|
if (e > 10) return { top: '#7ab846', bot: '#4d802c' }; // mid-day
|
||||||
if (e > 3) return { top: '#b8a83e', bot: '#7a6a26' }; // golden-hour glow
|
if (e > 3) return { top: '#b8a83e', bot: '#7a6a26' }; // golden-hour glow
|
||||||
if (e > -3) return { top: '#c08048', bot: '#7a4a2a' }; // sunrise/sunset embers
|
if (e > -3) return { top: '#c08048', bot: '#7a4a2a' }; // sunrise/sunset embers
|
||||||
|
|||||||
Reference in New Issue
Block a user