New SunSoak multiselect UTCI modifiers pulldown
Linked new SunSoak to profiles
Updated content to match new changes
This commit is contained in:
fraxle
2026-05-25 11:24:46 +01:00
parent 2cb4ebe52b
commit 2f76f98632
8 changed files with 239 additions and 93 deletions
+49 -17
View File
@@ -30,7 +30,7 @@ import {
} from './utils.js';
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js';
import { getCellTagEvents, getUpcomingEvents } from './events.js';
import { POLLEN_TYPES, COL_DESCRIPTIONS } from './config.js';
import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS } from './config.js';
import { useAppState } from './hooks/useAppState.js';
import { DayTabs } from './components/DayTabs.js';
@@ -64,6 +64,7 @@ export function UTCIForecast() {
indoorManaged, setIndoorManaged,
indoorMode, setIndoorMode,
pollenType, setPollenTypeAndSave,
utciEnv, setUtciEnv,
headStickyRef, headTrackRef, headTableRef,
bodyScrollRef, bodyTableRef, tableWrapRef,
colPopup, colPopupRef,
@@ -334,11 +335,34 @@ export function UTCIForecast() {
/>`}
${(isPro || activeCols['burn'] || activeCols['vehicleT'] || activeCols['indoorT'] || activeCols['managedT'] || activeCols['pollen']) && html`
${(isPro || activeCols['utciP'] || activeCols['burn'] || activeCols['vehicleT'] || activeCols['indoorT'] || activeCols['managedT'] || activeCols['pollen']) && html`
<div class="col-toggles">
<span class="col-toggles-label">Columns:</span>
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>UTCI+P</button>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP']) && html`
<span class=${'col-toggle-group' + (visibleCols.utciP ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
value=${utciEnv}
isOn=${visibleCols.utciP}
grpClass="grp-felt"
hideLabel="SunSoak"
hidingLabel="Hide SunSoak"
groupedLeft=${true}
isLastChild=${true}
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({
value: k,
label: v.label,
}))}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, utciP: false }));
} else {
setUtciEnv(v);
setVisibleCols(prev => ({ ...prev, utciP: true }));
}
}}
/>
</span>`}
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']) && html`
<span class=${'col-toggle-group' + (visibleCols.vehicleT ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
@@ -491,7 +515,7 @@ ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeC
${groupLabelRow()}
<tr>
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>Hour</th>
${visibleCols.utciP && html`<th class=${`col-info-th ${groupStart('utciP')} ${groupColor('utciP')}`} scope="col" onClick=${(e) => handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>UTCI+P <span class="col-unit">°C adj.</span></th>`}
${visibleCols.utciP && html`<th class=${`col-info-th ${groupStart('utciP')} ${groupColor('utciP')}`} scope="col" onClick=${(e) => handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>SunSoak <span class="col-unit">°C felt</span></th>`}
${visibleCols.vehicleT && html`<th class=${`col-info-th ${groupStart('vehicleT')} ${groupColor('vehicleT')}`} scope="col" onClick=${(e) => handleThClick('vehicleT', e)} onMouseEnter=${(e) => handleThEnter('vehicleT', e)} onMouseLeave=${handleThLeave}>Vehicle <span class="col-unit">°C peak</span></th>`}
${indoorMode === 'on' && !indoorManaged && html`<th class=${`col-info-th ${groupStart('indoorT')} ${groupColor('indoorT')}`} scope="col" onClick=${(e) => handleThClick('indoorT', e)} onMouseEnter=${(e) => handleThEnter('indoorT', e)} onMouseLeave=${handleThLeave}>Indoors <span class="col-unit">°C est.</span></th>`}
${indoorMode === 'on' && indoorManaged && html`<th class=${`col-info-th ${groupStart('managedT')} ${groupColor('managedT')}`} scope="col" onClick=${(e) => handleThClick('managedT', e)} onMouseEnter=${(e) => handleThEnter('managedT', e)} onMouseLeave=${handleThLeave}>Managed <span class="col-unit">°C est.</span></th>`}
@@ -879,26 +903,34 @@ ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeC
<p class="utci-about-text">
SunScope is a free hourly weather forecast built around <strong>felt temperature</strong>,
not just air temperature. It uses the <strong>Universal Thermal Climate Index (UTCI)</strong>
the biometeorological standard used in heat-health warning systems worldwide to combine
air temperature, humidity, wind, and solar radiation into a single honest number. The
<strong>UTCI+P</strong> column adds an original rain and snow penalty so wet, windy days
read as cold as they feel.
the biometeorological standard used in heat-health warning systems worldwide as its
foundation, combining air temperature, humidity, wind, and solar radiation into a single
honest number. Then it goes further. Our <strong>SunSoak</strong> index layers three extra
dimensions on top: a rain and snow penalty so wet, windy days read as cold as they feel;
an <strong>environment modifier</strong> that adjusts the solar load for where you actually
are forest canopy, alpine altitude, lakeside glare, shaded riverbank, desert ground heat;
and the full radiant heat absorbed from surrounding surfaces. One number that honestly
answers: <em>what will my body actually feel out there?</em>
</p>
<p class="utci-about-text">
Beyond felt temperature, SunScope calculates <strong>vehicle cabin heat</strong> (choose
your vehicle type; toggle windows open), <strong>indoor temperature</strong> (seven
building types; managed heatwave mode), <strong>urban concrete surface temperature</strong>,
<strong>UV index and sunburn time</strong> by skin type, and <strong>soil temperature
and moisture</strong> for farming and motorhome use. Switch profiles to see the data
that matters for your situation or go Custom and build your own view.
Beyond SunSoak, SunScope calculates <strong>vehicle cabin heat</strong> (choose your
vehicle type; toggle windows open), <strong>indoor temperature</strong> (seven building
types including office blocks; managed heatwave mode), <strong>urban concrete surface
temperature</strong>, <strong>UV index and sunburn time</strong> by skin type, and
<strong>soil temperature and moisture</strong> for farming and motorhome use. Switch
profiles Places, Activities, Work to see the data that matters for your situation,
or go Custom and build your own view.
<a href="./about.html" class="utci-about-link">Learn more </a>
</p>
</div>
<div class="utci-footer">
<em>Reading the table.</em> A large positive Δ means your body is absorbing
far more heat than the air temperature alone suggests typically due to direct solar radiation.
On clear sunny days this gap can exceed 10 °C even at modest air temperatures.
<em>Reading the table.</em> SunSoak is the number to watch it reflects everything your body
actually experiences, not just what the thermometer says. A large gap between SunSoak and Air
temperature means solar radiation is doing significant work on your body. On clear sunny days
that gap can exceed 10 °C even at modest air temperatures. The environment modifier in the
SunSoak dropdown adjusts the solar load for your surroundings switch it to match where you are
for the most accurate reading.
${isPro && html`
<div style=${{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid #d4c0a0' }}>
SunScope Extra is active.
+39 -11
View File
@@ -168,29 +168,57 @@ export function DayTabs({
const promptDate = new Date(days[proPromptDay].key + 'T00:00Z');
const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' });
const extraPromptCopy = {
'variant:sailing': {
title: 'Sailing is part of SunScope Extra',
detail: 'Extra adds specialist planning views for higher-commitment trips, including wind, exposure, UV, and wet-weather comfort for water conditions.',
},
// Profile buttons
'profile:alltemps': {
title: 'Temps is part of SunScope Extra',
detail: 'Extra unlocks the comparison view for air, soil, concrete, vehicle, and indoor temperatures in one place.',
detail: 'Extra unlocks the temperature comparison view air, soil, concrete, vehicle interior, and indoor estimates side by side in one place.',
},
'profile:showall': {
title: 'Show All is part of SunScope Extra',
detail: 'Extra unlocks the full column set — every data point SunScope tracks, visible at once for deep-dive analysis.',
},
'profile:custom': {
title: 'Custom columns are part of SunScope Extra',
detail: 'Extra lets you choose exactly which columns appear: mix and match Air, Dew, Soil, UV, UTCI and more to build your perfect view.',
detail: 'Extra lets you hand-pick exactly which columns appear. Mix and match air, dew, soil, UV, UTCI and more to build your perfect view.',
},
'variant:festival': {
title: 'Festival planning is part of SunScope Extra',
detail: 'Extra adds multi-day comfort, ground condition, exposure, and rain planning for higher-stakes outdoor trips.',
// Places
'variant:construction': {
title: 'Construction is part of SunScope Extra',
detail: 'Extra adds the Construction profile with felt temperature, wind, rain probability, visibility, and air quality — the key factors for safe site working.',
},
// Activities
'variant:hiking': {
title: 'Hiking is part of SunScope Extra',
detail: 'Extra adds the Hiking profile with felt temperature, UV index, burn time, wind direction, and visibility — essential for longer days on exposed terrain.',
},
'variant:photography': {
title: 'Photography is part of SunScope Extra',
detail: 'Extra adds the Photography profile with direct and diffuse radiation, sun elevation, cloud cover, and visibility — the conditions that make or break a shoot.',
},
'variant:sailing': {
title: 'Sailing is part of SunScope Extra',
detail: 'Extra adds the Sailing profile with wind speed and direction, dew point, UV, burn time, sun elevation, and visibility for on-water planning.',
},
'variant:wintersports': {
title: 'Winter Sports is part of SunScope Extra',
detail: 'Extra adds specialist exposure planning for snow, glare, wind, UV reflection, and cold-weather comfort.',
detail: 'Extra adds the Winter Sports profile with UV-A and UV-B, burn time, sun elevation, wind direction, and visibility for snow and slope conditions.',
},
'variant:naturist': {
title: 'Naturist is part of SunScope Extra',
detail: 'Extra adds specialist skin-exposure planning with UV, wind, humidity, precipitation, and felt-temperature detail.',
detail: 'Extra adds the Naturist profile with full skin-exposure detail — UV, burn time, felt temperature, dew point, humidity, and air quality.',
},
// Work
'variant:market': {
title: 'Market Trading is part of SunScope Extra',
detail: 'Extra adds the Market Trading profile with felt temperature, wind direction, rain probability, and visibility — the key factors for planning stall days.',
},
'variant:windowcleaning': {
title: 'Window Cleaning is part of SunScope Extra',
detail: 'Extra adds the Window Cleaning profile focused on wind speed and direction, rain, and felt temperature — the conditions that determine whether work is safe and worthwhile.',
},
'variant:office': {
title: 'Office is part of SunScope Extra',
detail: 'Extra adds the Office profile with solar gain, managed indoor temperature, humidity, air quality, and rain probability — useful for commute planning and building comfort.',
},
};
const promptCopy = extraPromptCopy[proPromptSource] || {
+10 -3
View File
@@ -16,8 +16,10 @@ import {
calcIndoorTempPass, calcManagedIndoorTempPass,
} from './physics.js';
import { windCompass8, uvSplit, cloudCategory, precipPenalty } from './utils.js';
import { UTCI_ENVIRONMENTS } from './config.js';
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, buildingType }) {
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, buildingType, utciEnv }) {
const env = UTCI_ENVIRONMENTS[utciEnv] ?? UTCI_ENVIRONMENTS.open;
const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000;
// Build a fast lookup map from the air quality hourly data: ISO string - index.
@@ -75,8 +77,13 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
// concreteT is now stamped in the two-pass section below (thermal lag).
const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent);
const eh = vaporPressureHpa(Ta, RH);
const Tmrt = calcTmrt(Ta, dir, dif, glob, elev);
const utci = utciApprox(Ta, Tmrt, va, eh);
// Apply environment modifier - adjust solar inputs and air temp for shaded environments.
const TaEnv = Ta + env.taOffset;
const dirEnv = dir * env.dirFactor;
const difEnv = dif * env.difFactor;
const globEnv = glob * env.globFactor;
const Tmrt = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev);
const utci = utciApprox(TaEnv, Tmrt, va, eh);
const utciAdj = utci + precipPenalty(precip, snow, va);
// Derived
const compass = windCompass8(wd);
+40 -5
View File
@@ -10,6 +10,7 @@
// FILTER_PROFILES ............ preset column sets (Basic, Home, etc.)
// OUTDOORS_VARIANTS .......... Places & Activities sub-variants
// POLLEN_TYPES ............... pollen-column pulldown options
// UTCI_ENVIRONMENTS .......... environment modifiers for UTCI+P column
// variantIcons ............... emoji glyph per place/activity variant
// profileButtonOrder ......... left-to-right order of the profile buttons
// activityVariantKeys ........ which variants appear in the Activities menu
@@ -77,12 +78,13 @@ export const OUTDOORS_VARIANTS = {
urban: { name: 'Urban', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false } },
beach: { name: 'Beach', cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
events: { name: 'Events', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
festival: { name: 'Festival', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } },
festival: { name: 'Festival', cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } },
wintersports: { name: 'Winter Sports', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
naturist: { name: 'Naturist', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } },
sailing: { name: 'Sailing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
// Places - free
park: { name: 'Park / Picnic', 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: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
forest: { name: 'Forest / Woodland', 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: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: true } },
airport: { name: 'Airport / Travel', 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: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
// Places - Pro
construction: { name: 'Construction', proOnly: true, 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: true, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false } },
@@ -93,9 +95,9 @@ export const OUTDOORS_VARIANTS = {
// Activities - Pro
hiking: { name: 'Hiking', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
photography: { name: 'Photography', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
fishing: { name: 'Fishing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
fishing: { name: 'Fishing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
// Work variants
market: { name: 'Market Trading', 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: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
market: { name: 'Market Trading', proOnly: true, 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: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
windowcleaning: { name: 'Window Cleaning', proOnly: true, 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: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } },
office: { name: 'Office', proOnly: true, cols: { hour: true, air: true, rh: true, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: true, vis: false, aqi: true, pollen: false } },
};
@@ -111,6 +113,38 @@ export const POLLEN_TYPES = {
ragweed_pollen: { name: 'Ragweed pollen', unit: 'grains/m³' },
};
// UTCI+P environment modifiers - affect solar inputs and air temp used
// in the UTCI calculation to reflect shaded or modified environments.
// dirFactor - fraction of direct radiation that reaches the person (0-1)
// difFactor - fraction of diffuse radiation that reaches the person (0-1)
// globFactor - fraction of global shortwave radiation that reaches the person (0-1)
// taOffset - offset applied to air temperature in degrees C (evapotranspiration etc.)
// label - short display name
// note - shown in column header tooltip to flag modified calculation
export const UTCI_ENVIRONMENTS = {
open: { label: 'Open / Exposed', dirFactor: 1.00, difFactor: 1.00, globFactor: 1.00, taOffset: 0.0, note: null },
forest: { label: 'Forest / Woodland', dirFactor: 0.15, difFactor: 0.40, globFactor: 0.20, taOffset: -2.0, note: 'Canopy-shaded estimate - direct radiation reduced 85%, air temp adjusted for evapotranspiration cooling' },
alpine: { label: 'High Altitude / Alpine', dirFactor: 1.15, difFactor: 1.10, globFactor: 1.12, taOffset: -1.0, note: 'Alpine estimate assuming approx. 2000 m elevation - radiation enhanced ~15% through thinner atmosphere, adjusted for exposed ridge conditions' },
beach: { label: 'Beach / Lakeside', dirFactor: 1.12, difFactor: 1.06, globFactor: 1.10, taOffset: 0.0, note: 'Reflected radiation from water and sand surfaces increases effective solar load - no shade, higher UV dose' },
river: { label: 'River / Riverbank', dirFactor: 0.50, difFactor: 0.60, globFactor: 0.55, taOffset: -2.5, note: 'Shaded riverbank estimate - canopy and valley walls reduce direct sun, evaporative cooling from water surface lowers apparent air temp' },
desert: { label: 'Desert / Arid', dirFactor: 1.22, difFactor: 1.10, globFactor: 1.18, taOffset: 3.5, note: 'Extreme radiation load from high albedo sand and bare ground - ground-level heat significantly raises effective felt temperature' },
};
// Maps OUTDOORS_VARIANTS keys to a default UTCI_ENVIRONMENTS key.
// When a variant is selected via the Places or Activities dropdown the
// UTCI+P environment modifier is automatically switched to match.
// Variants not listed here default to open.
export const VARIANT_DEFAULT_ENV = {
forest: 'forest',
park: 'forest',
beach: 'beach',
sailing: 'beach',
hiking: 'alpine',
wintersports:'alpine',
fishing: 'river',
naturist: 'beach',
};
// Left-to-right order of the profile buttons in the top filter bar.
export const profileButtonOrder = ['basic', 'home', 'vehicle', 'alltemps', 'showall', 'custom'];
@@ -120,6 +154,7 @@ export const variantIcons = {
beach: '🏖️',
events: '🎪',
park: '🌳',
forest: '🌲',
airport: '✈️',
festival: '⛺',
construction: '🏗️',
@@ -141,7 +176,7 @@ export const variantIcons = {
export const activityVariantKeys = ['dogwalk', 'running', 'cycling', 'hiking', 'fishing', 'sailing', 'wintersports', 'photography', 'naturist'];
// Which variant keys appear in the Places dropdown.
export const placeVariantKeys = ['urban', 'park', 'beach', 'events', 'festival', 'airport'];
export const placeVariantKeys = ['urban', 'park', 'forest', 'beach', 'events', 'festival', 'airport'];
// Which variant keys appear in the Work dropdown.
export const workVariantKeys = ['farming', 'construction', 'market', 'windowcleaning', 'office'];
@@ -164,7 +199,7 @@ export const COL_DESCRIPTIONS = {
uvA: { title: 'UV-A Index', desc: '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.' },
uvB: { title: 'UV-B Index', desc: 'Estimated UV-B radiation index. UV-B causes sunburn and is the main driver of vitamin D production. Intensity depends strongly on solar elevation and cloud cover.' },
burn: { title: 'Burn Time', desc: 'Estimated time to reach one Minimal Erythemal Dose (MED) — the threshold for sunburn — based on the UV index and your selected skin type. This is a guide, not a medical measurement.' },
utciP: { title: 'UTCI+P', desc: 'SunScope\'s adjusted felt temperature: UTCI plus the soak-factor penalty for precipitation. Rain and snow on wet clothing can reduce the felt temperature by up to 8 °C.' },
utciP: { title: 'SunSoak', desc: 'SunScope\'s signature felt-temperature index. Starts from UTCI - the biometeorological standard combining air temp, humidity, wind and solar radiation - then layers in three extra dimensions: a rain and snow penalty (wet clothing can drop felt temp by up to 8 °C), an environment modifier that adjusts solar load for where you actually are (forest canopy, alpine altitude, lakeside reflection, shaded riverbank, desert ground heat), and the full radiant heat from your surroundings. One number that honestly answers: what will my body actually feel out there?' },
precip: { title: 'Precipitation', desc: 'Expected rainfall or snowfall in mm per hour. Snow is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.' },
precipProb: { title: 'Rain Probability', desc: 'Probability of precipitation for this hour as a percentage (0-100%). Derived from Open-Meteo ensemble model runs. A high value means rain is likely even if the expected rate is low - useful for spotting showers that the deterministic forecast may miss.' },
soilT: { title: 'Soil Temperature', desc: 'Temperature of the soil at the surface (0 cm depth). Useful for planting decisions — most seeds germinate above 710 °C.' },
+20 -1
View File
@@ -27,6 +27,7 @@ import {
FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS,
profileButtonOrder, variantIcons,
activityVariantKeys, placeVariantKeys, workVariantKeys,
UTCI_ENVIRONMENTS, VARIANT_DEFAULT_ENV,
} from '../config.js';
import { utciCategory } from '../utils.js';
import { buildHourlyRows } from '../compute.js';
@@ -196,6 +197,9 @@ export function useAppState() {
const hasIndoor = variantCols['indoorT'] || variantCols['managedT'];
setIndoorMode(hasIndoor ? 'on' : 'off');
}
// Auto-set UTCI environment modifier to match the selected variant.
const defaultEnv = VARIANT_DEFAULT_ENV[v] ?? 'open';
setUtciEnvAndSave(defaultEnv);
};
const activeCols = activeProfile === 'outdoors'
@@ -244,6 +248,20 @@ export function useAppState() {
try { localStorage.setItem('sunscope_pollen_type', v); } catch (e) { /* ignore */ }
setPollenType(v);
};
const [utciEnv, setUtciEnv] = useState(() => {
try {
const saved = localStorage.getItem('sunscope_utci_env');
if (saved && UTCI_ENVIRONMENTS[saved]) return saved;
// No saved env - derive from the saved variant if it has a default.
const savedVariant = localStorage.getItem('sunscope_outdoors_variant') || '';
return VARIANT_DEFAULT_ENV[savedVariant] ?? 'open';
} catch (e) { return 'open'; }
});
const setUtciEnvAndSave = (v) => {
try { localStorage.setItem('sunscope_utci_env', v); } catch (e) { /* ignore */ }
setUtciEnv(v);
};
const [showDecimals, setShowDecimals] = useState(() => {
try { const s = localStorage.getItem('sunscope_show_decimals'); return s === null ? false : s !== '0'; } catch (e) { return false; }
@@ -359,7 +377,7 @@ export function useAppState() {
// ── 9. COMPUTATION ───────────────────────────────────────────────────
const { hourlyRows, days, utcOffsetMs } = buildHourlyRows({
forecast, airQuality, location, vehicleType, vehicleVent, buildingType,
forecast, airQuality, location, vehicleType, vehicleVent, buildingType, utciEnv,
});
const visible = days[selectedDay]?.rows || [];
@@ -497,6 +515,7 @@ export function useAppState() {
indoorManaged, setIndoorManaged: setIndoorManagedAndSave,
indoorMode, setIndoorMode: setIndoorModeAndSave,
pollenType, setPollenTypeAndSave,
utciEnv, setUtciEnv: setUtciEnvAndSave,
showDecimals, toggleShowDecimals,
showUnits, toggleShowUnits,
// table refs