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.
|
||||
</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>
|
||||
<p>
|
||||
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; }
|
||||
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 {
|
||||
background: #fef8ea;
|
||||
border: 1px solid #e0d0a8;
|
||||
|
||||
+96
-49
@@ -27,6 +27,7 @@ import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcrete
|
||||
import {
|
||||
utciCategory, precipPenalty, windCompass8, uvSplit,
|
||||
SKIN_TYPES, sunburnMinutes, burnLabel,
|
||||
VEHICLE_TYPES,
|
||||
cloudCategory, confidenceBand, moonGlyph,
|
||||
} from './utils.js';
|
||||
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon } from './components.js';
|
||||
@@ -55,6 +56,7 @@ export function UTCIForecast() {
|
||||
const [searchResults, setSearchResults] = useState([]); // geocoding dropdown
|
||||
const [searching, setSearching] = useState(false); // search-in-flight flag
|
||||
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 [proPromptSource, setProPromptSource] = useState('day'); // 'day' | 'custom'
|
||||
|
||||
@@ -111,7 +113,7 @@ export function UTCIForecast() {
|
||||
// FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE.
|
||||
// When this is wired to real billing/auth, replace `useState(false)`
|
||||
// with a check against the logged-in user.
|
||||
const [isPro, setIsPro] = useState(false);
|
||||
const [isPro, setIsPro] = useState(true);
|
||||
|
||||
// How many days the free tier shows. Days beyond this get a 🔒.
|
||||
// Bump this number if you want to give free users more access.
|
||||
@@ -125,30 +127,30 @@ export function UTCIForecast() {
|
||||
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 },
|
||||
},
|
||||
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: {
|
||||
label: 'Urban',
|
||||
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 },
|
||||
},
|
||||
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 },
|
||||
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 },
|
||||
},
|
||||
vehicle: {
|
||||
label: 'Vehicle',
|
||||
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: {
|
||||
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: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true },
|
||||
sailing: {
|
||||
label: 'Sailing',
|
||||
icon: '⛵',
|
||||
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: {
|
||||
label: 'All Temps',
|
||||
@@ -168,19 +170,14 @@ export function UTCIForecast() {
|
||||
// Which columns appear in the hourly table by default.
|
||||
// true = visible on first load (and the only ones free users see)
|
||||
// false = hidden by default (Pro users can toggle these on)
|
||||
const [visibleCols, setVisibleCols] = useState({
|
||||
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 [visibleCols, setVisibleCols] = useState({ ...FILTER_PROFILES.basic.cols });
|
||||
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
|
||||
|
||||
// Skin type for the sunburn-time column. Fitzpatrick II is typical UK fair.
|
||||
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);
|
||||
|
||||
// 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.' },
|
||||
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.' },
|
||||
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.' },
|
||||
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();
|
||||
window.removeEventListener('resize', sync);
|
||||
};
|
||||
}, [forecast, visibleCols, selectedDay, skinType]);
|
||||
}, [forecast, visibleCols, selectedDay, skinType, vehicleType]);
|
||||
|
||||
// Geocoding search
|
||||
useEffect(() => {
|
||||
@@ -503,6 +500,37 @@ export function UTCIForecast() {
|
||||
load();
|
||||
}, [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 ─────────────────────────────────────────────────────
|
||||
// Take the raw API arrays and stitch them into one object per hour,
|
||||
// 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.
|
||||
const dt = 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 Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
|
||||
const utci = utciApprox(Ta, Tmrt, va, eh);
|
||||
@@ -592,7 +620,6 @@ export function UTCIForecast() {
|
||||
});
|
||||
|
||||
const visible = days[selectedDay]?.rows || [];
|
||||
const now = new Date();
|
||||
// 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).
|
||||
const nowLocalISO = new Date(now.getTime() + utcOffsetMs)
|
||||
@@ -653,6 +680,7 @@ export function UTCIForecast() {
|
||||
loading=${loading}
|
||||
elev=${currentRow?.elev ?? 0}
|
||||
dt=${currentRow?.dt ?? new Date()}
|
||||
glob=${currentRow?.glob ?? 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -938,6 +966,43 @@ export function UTCIForecast() {
|
||||
COLUMN TOGGLES — Pro only. Hidden entirely for free users
|
||||
(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`
|
||||
<div class="col-toggles">
|
||||
<span class="col-toggles-label">Columns:</span>
|
||||
@@ -950,7 +1015,6 @@ export function UTCIForecast() {
|
||||
{ key: 'soilT6', label: 'Soil 6cm' },
|
||||
{ key: 'soilM', label: 'Soil moist' },
|
||||
{ key: 'concreteT', label: 'Concrete' },
|
||||
{ key: 'vehicleT', label: 'Vehicle' },
|
||||
{ key: 'indoorT', label: 'Indoors' },
|
||||
{ key: 'managedT', label: 'Managed' },
|
||||
{ key: 'wind', label: 'Wind' },
|
||||
@@ -973,23 +1037,6 @@ export function UTCIForecast() {
|
||||
onClick=${() => toggleCol(c.key)}
|
||||
>${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`
|
||||
<button
|
||||
class=${`col-toggle${visibleCols.precip ? ' on' : ''}`}
|
||||
@@ -1079,7 +1126,7 @@ export function UTCIForecast() {
|
||||
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
|
||||
<td class="utci-time">
|
||||
<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>
|
||||
</td>
|
||||
|
||||
+36
-16
@@ -39,7 +39,7 @@ const html = htm.bind(h);
|
||||
// • sunR — the sun's drawn radius
|
||||
// • 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 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 sunR = Math.max(2.2, innerR * 0.32);
|
||||
|
||||
// Near-horizon sun gets a warm glow halo
|
||||
const sunGlowOpacity = elev < 10 ? Math.max(0, 1 - elev / 10) * 0.65 : 0.25;
|
||||
const sunGlowR = sunR + (elev < 6 ? 3.5 : 1.8);
|
||||
const sunGlowColor = isRising ? '#ff8c3a' : '#ff9a6a';
|
||||
// Radial glow: strength driven by glob (W/m²). Uses a radialGradient
|
||||
// so it's intense at the sun centre and fades smoothly to transparent.
|
||||
const radFrac = Math.min(1, (glob || 0) / 900); // 0 (dark) → 1 (blazing)
|
||||
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 moonR = innerR * 0.55;
|
||||
@@ -94,6 +97,14 @@ export function SkyScope({ elev, dt, size = 26 }) {
|
||||
<stop offset="0%" stop-color=${grass.top} />
|
||||
<stop offset="100%" stop-color=${grass.bot} />
|
||||
</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 -->
|
||||
<mask id=${`moon-mask-${uid}`}>
|
||||
<circle cx=${r} cy=${r} r=${moonR} fill="white" />
|
||||
@@ -111,10 +122,8 @@ export function SkyScope({ elev, dt, size = 26 }) {
|
||||
|
||||
${isDay && html`
|
||||
<g clip-path=${`url(#${clipId})`}>
|
||||
<!-- Warm glow halo — bigger and more vivid near the horizon -->
|
||||
<circle cx=${r} cy=${sunY} r=${sunGlowR} fill=${sunGlowColor} opacity=${sunGlowOpacity} />
|
||||
<!-- Sun disk -->
|
||||
<circle cx=${r} cy=${sunY} r=${sunR} fill="#fff8c0" stroke="#e6a32a" stroke-width="0.5" />
|
||||
<!-- Glow + sun disk as one seamless radial gradient circle -->
|
||||
<circle cx=${r} cy=${sunY} r=${glowR} fill=${`url(#glow-${uid})`} />
|
||||
</g>`}
|
||||
|
||||
${(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 —
|
||||
// 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 ─────────────────────────────
|
||||
// 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
|
||||
@@ -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 sunY = cy - Math.sin((elevClamped * Math.PI) / 180) * (lensR - 18);
|
||||
const sunDrawR = 14;
|
||||
// Near-horizon glow for the big dial
|
||||
const sunGlowR = sunDrawR + (elev < 10 ? 7 : 3);
|
||||
const sunGlowOp = elev < 10 ? Math.max(0, 1 - elev / 10) * 0.55 : 0.2;
|
||||
const sunGlowCol = isRising ? '#ff8c3a' : '#ff9a6a';
|
||||
// Radial glow for the big dial — sun is always at least faintly glowing
|
||||
// when above the horizon; scales up with radiation strength.
|
||||
const radFrac = Math.min(1, (glob || 0) / 900);
|
||||
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 moonY = cy - 45;
|
||||
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="100%" stop-color=${grass.bot} />
|
||||
</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 -->
|
||||
<mask id="scope-moon-mask">
|
||||
<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`}
|
||||
fill="url(#scope-lens-grass)" />
|
||||
${isDay && html`<${Fragment}>
|
||||
<circle cx=${cx} cy=${sunY} r=${sunGlowR} fill=${sunGlowCol} opacity=${sunGlowOp} />
|
||||
<circle cx=${cx} cy=${sunY} r=${sunDrawR} fill="#fff8c0" stroke="#e6a32a" stroke-width="0.8" />
|
||||
<!-- Glow + sun disk as one seamless radial gradient circle -->
|
||||
<circle cx=${cx} cy=${sunY} r=${sunGlowR} fill="url(#scope-sun-glow)" />
|
||||
</>`}
|
||||
${(isTwil || (!isDay && !isTwil)) && html`<${Fragment}>
|
||||
<!-- Lit crescent — full moon disk clipped by the phase mask -->
|
||||
|
||||
+12
-8
@@ -7,11 +7,14 @@
|
||||
// solarElevationDeg(lat, lon, dateUTC) NOAA simplified solar position
|
||||
// calcTmrt(Ta, dirRad, diffRad, globalRad, solElev) Mean radiant temp
|
||||
// utciApprox(Ta, Tmrt, va10, ehPa) Bröde et al. 2012 polynomial
|
||||
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType)
|
||||
//
|
||||
// Nothing in here should need editing unless the underlying science
|
||||
// changes. All numbers are peer-reviewed constants or coefficients.
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { VEHICLE_TYPES } from './utils.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PHYSICAL CONSTANTS
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
@@ -262,12 +265,14 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr) {
|
||||
// 35–45 °C — dangerous for children/pets (hyperthermia risk)
|
||||
// > 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;
|
||||
|
||||
// Look up vehicle preset; fall back to a standard car if key unknown.
|
||||
const preset = VEHICLE_TYPES[vehicleType] || VEHICLE_TYPES.car;
|
||||
|
||||
// ── 1. Panel conduction ──────────────────────────────────────────
|
||||
const albedoPanel = 0.25; // typical mixed fleet
|
||||
const panelAbsorbed = globalRad * (1 - albedoPanel); // W/m² absorbed by bodywork
|
||||
const panelAbsorbed = globalRad * (1 - preset.albedo); // W/m² absorbed by bodywork
|
||||
// Panel surface temp: absorbed solar / convective loss to outside air
|
||||
// hOut ~10 W/m²K (light breeze over panel surface even when parked)
|
||||
const hOut = 10;
|
||||
@@ -277,8 +282,8 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev) {
|
||||
const conductionGain = hCabin * (panelSurfaceTemp - Ta); // W/m²
|
||||
|
||||
// ── 2. Side-window glazing gain (angle-dependent) ─────────────────
|
||||
// Glazing transmission for auto glass ~0.70
|
||||
const tau = 0.70;
|
||||
// Glazing transmission for auto glass ~0.70; scaled by vehicle glazing area.
|
||||
const tau = 0.70 * preset.glazingArea;
|
||||
let glazingGain = 0;
|
||||
if (solElev != null && solElev > 10 && solElev < 60) {
|
||||
// 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 ───────────────────────────
|
||||
// Total heat input per m² of cabin surface
|
||||
const totalGain = conductionGain + glazingGain;
|
||||
// Cabin heat loss: poor natural ventilation in sealed car ~2.0 W/m²K
|
||||
const hCabinLoss = 2.0;
|
||||
const Ti = Ta + totalGain / hCabinLoss;
|
||||
// Cabin heat loss: varies by vehicle type (motorhomes insulated, cars not)
|
||||
const Ti = Ta + totalGain / preset.hCabinLoss;
|
||||
|
||||
// Clamp: can't be cooler than outside air; physical cap at 90 °C
|
||||
return Math.max(Ta, Math.min(Ti, 90));
|
||||
|
||||
+77
-35
@@ -9,6 +9,7 @@
|
||||
// SKIN_TYPES Fitzpatrick skin type table
|
||||
// sunburnMinutes(uv, skinType) minutes to MED
|
||||
// burnLabel(mins) formats burn time as "12m"/"1.5h"
|
||||
// VEHICLE_TYPES vehicle presets for cabin heat model
|
||||
// cloudCategory(total,low,mid,high) → 'clear'|'wispy'|'scattered'|'overcast'
|
||||
// confidenceBand(i) day-tab gradient + label
|
||||
// moonPhaseFraction(date) 0..1 synodic phase
|
||||
@@ -122,6 +123,23 @@ export function burnLabel(mins) {
|
||||
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.
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
@@ -220,49 +238,73 @@ export function moonGlyph(p) {
|
||||
// Sunset palette → pinks, purples, mauves at the horizon.
|
||||
// Both share the same deep blue zenith at high elevations.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
export function skyGradientForElev(e, isRising) {
|
||||
// Full daytime — blue sky, paler toward horizon
|
||||
if (e > 30) return { top: '#5bb8e8', bot: '#9fd3ef' };
|
||||
if (e > 10) return { top: '#7cc5ec', bot: '#c8e3ee' };
|
||||
// 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'],
|
||||
];
|
||||
|
||||
// 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
|
||||
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' };
|
||||
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));
|
||||
}
|
||||
|
||||
// Convenience flat colour — returns the bot (horizon) colour for contexts
|
||||
// that still need a single fill (e.g. moon shadow fill).
|
||||
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) {
|
||||
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) {
|
||||
return skyGradientForElev(e, isRising).bot;
|
||||
}
|
||||
|
||||
// Grass palette — mirrors skyFillForElev but for the ground.
|
||||
// `top` = grass blade tips (catches sky light), `bot` = soil shadow.
|
||||
// Grass palette — mirrors sky but for the ground strip.
|
||||
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 > 3) return { top: '#b8a83e', bot: '#7a6a26' }; // golden-hour glow
|
||||
if (e > -3) return { top: '#c08048', bot: '#7a4a2a' }; // sunrise/sunset embers
|
||||
|
||||
Reference in New Issue
Block a user