Improved scope
Improved profiles
This commit is contained in:
fraxle
2026-05-15 09:40:42 +01:00
parent f024fd0c2d
commit 5b0e62d1b5
6 changed files with 372 additions and 108 deletions
+128
View File
@@ -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 24 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 2030 °C on a sunny day; this is why shade feels so much cooler.</td></tr>
<tr><td><strong>UTCIAir Δ</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 710 °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 1525 °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 24 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
+23
View File
@@ -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
View File
@@ -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 1525 °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 1525 °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 24 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 24 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
View File
@@ -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
View File
@@ -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) {
// 3545 °C — dangerous for children/pets (hyperthermia risk) // 3545 °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 3040° elevation (sun cuts squarely // Scale factor: peaks around 3040° 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
View File
@@ -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.
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// Sky colour keyframes — [elevation, topHex, botHex] for rising and setting sun.
// Colours are smoothly interpolated between adjacent keyframes.
const SKY_KEYS_RISING = [
[-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'],
];
function hexToRgb(hex) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHex([r, g, b]) {
return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
}
function lerpRgb(a, b, t) {
return a.map((v, i) => v + t * (b[i] - v));
}
function interpolateSkyKeys(keys, e) {
// 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) { export function skyGradientForElev(e, isRising) {
// Full daytime — blue sky, paler toward horizon return interpolateSkyKeys(isRising ? SKY_KEYS_RISING : SKY_KEYS_SETTING, e);
if (e > 30) return { top: '#5bb8e8', bot: '#9fd3ef' };
if (e > 10) return { top: '#7cc5ec', bot: '#c8e3ee' };
// Low sun — golden hour / near-horizon
if (e > 3) {
return isRising
? { top: '#6aaddb', bot: '#ffb347' } // sunrise: blue → warm amber/orange
: { top: '#7b8fc4', bot: '#ffb877' }; // sunset: blue-violet → golden
} }
// Sun at/just below horizon — the dramatic band // Convenience flat colour for contexts needing a single fill.
if (e > -3) {
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
// that still need a single fill (e.g. moon shadow 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