More 'Home' Physics
More FAQs
More Text
This commit is contained in:
fraxle
2026-05-15 10:39:09 +01:00
parent 5b0e62d1b5
commit 01d939933a
7 changed files with 605 additions and 176 deletions
+130 -86
View File
@@ -27,7 +27,7 @@ import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcrete
import {
utciCategory, precipPenalty, windCompass8, uvSplit,
SKIN_TYPES, sunburnMinutes, burnLabel,
VEHICLE_TYPES,
VEHICLE_TYPES, BUILDING_TYPES,
cloudCategory, confidenceBand, moonGlyph,
} from './utils.js';
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon } from './components.js';
@@ -113,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(true);
const [isPro, setIsPro] = useState(false);
// How many days the free tier shows. Days beyond this get a 🔒.
// Bump this number if you want to give free users more access.
@@ -178,6 +178,16 @@ export function UTCIForecast() {
// Vehicle type for the cabin heat column. 'car' is the default preset.
const [vehicleType, setVehicleType] = useState('car');
// Vehicle ventilation — true = windows open (high convective loss).
const [vehicleVent, setVehicleVent] = useState(false);
// Indoor: buildingType drives the physics preset; indoorManaged toggles the
// curtains+ventilation model on top. indoorMode gates column visibility:
// 'off' = hidden, 'on' = indoorT shown (managed or not depending on indoorManaged).
const [buildingType, setBuildingType] = useState('brick');
const [indoorManaged, setIndoorManaged] = useState(false);
const [indoorMode, setIndoorMode] = useState('off');
const searchTimeout = useRef(null);
// Refs for the two-scroller table layout (sticky-to-viewport header +
@@ -579,7 +589,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, vehicleType);
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);
@@ -605,8 +615,8 @@ export function UTCIForecast() {
const TaArr = hourlyRows.map(r => r.Ta);
const globArr = hourlyRows.map(r => r.glob);
const elevArr = hourlyRows.map(r => r.elev);
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr);
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr);
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType);
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType);
hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; });
}
@@ -957,19 +967,48 @@ export function UTCIForecast() {
}
setActiveProfile(key);
if (key !== 'custom') setVisibleCols({ ...profile.cols });
// Auto-enable indoor column for profiles that include it;
// reset to off for profiles that don't.
if (key !== 'custom') {
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
setIndoorMode(hasIndoor ? 'on' : 'off');
setIndoorManaged(false);
}
}}
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
})}
</div>
<!--
COLUMN TOGGLES — Pro only. Hidden entirely for free users
(the profile selector above provides enough control for them).
COLUMN TOGGLES — in exact table column order.
Buttons visible to all users when profile includes that col.
Burn + Vehicle dropdowns always shown (free + pro).
Pro users see all toggles; free users see profile-filtered subset.
-->
${(FILTER_PROFILES[activeProfile]?.cols['burn'] || FILTER_PROFILES[activeProfile]?.cols['vehicleT']) && html`
${(isPro || FILTER_PROFILES[activeProfile]?.cols['burn'] || FILTER_PROFILES[activeProfile]?.cols['vehicleT'] || FILTER_PROFILES[activeProfile]?.cols['indoorT'] || FILTER_PROFILES[activeProfile]?.cols['managedT']) && html`
<div class="col-toggles">
<span class="col-toggles-label">Columns:</span>
${FILTER_PROFILES[activeProfile]?.cols['burn'] && html`
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['air']) && html`<button class=${`col-toggle${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['rh']) && html`<button class=${`col-toggle${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dew']) && html`<button class=${`col-toggle${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilT']) && html`<button class=${`col-toggle${visibleCols.soilT ? ' on' : ''}`} onClick=${() => toggleCol('soilT')}>Soil °C</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilT6']) && html`<button class=${`col-toggle${visibleCols.soilT6 ? ' on' : ''}`} onClick=${() => toggleCol('soilT6')}>Soil 6cm</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilM']) && html`<button class=${`col-toggle${visibleCols.soilM ? ' on' : ''}`} onClick=${() => toggleCol('soilM')}>Soil moist</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['concreteT']) && html`<button class=${`col-toggle${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['wind']) && html`<button class=${`col-toggle${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dir']) && html`<button class=${`col-toggle${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['cloud']) && html`<button class=${`col-toggle${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['sun']) && html`<button class=${`col-toggle${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['direct']) && html`<button class=${`col-toggle${visibleCols.direct ? ' on' : ''}`} onClick=${() => toggleCol('direct')}>Direct</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['diffuse']) && html`<button class=${`col-toggle${visibleCols.diffuse ? ' on' : ''}`} onClick=${() => toggleCol('diffuse')}>Diffuse</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['tmrt']) && html`<button class=${`col-toggle${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['delta']) && html`<button class=${`col-toggle${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['utci']) && html`<button class=${`col-toggle${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['uvA']) && html`<button class=${`col-toggle${visibleCols.uvA ? ' on' : ''}`} onClick=${() => toggleCol('uvA')}>UV-A</button>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['uvB']) && html`<button class=${`col-toggle${visibleCols.uvB ? ' on' : ''}`} onClick=${() => toggleCol('uvB')}>UV-B</button>`}
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['burn']) && html`
<select
class=${`col-toggle burn-select${visibleCols.burn ? ' on' : ''}`}
value=${visibleCols.burn ? skinType : 'off'}
@@ -985,63 +1024,72 @@ export function UTCIForecast() {
${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>
${[
// Hour and UTCI+P are always-on — no toggle button for them.
{ key: 'air', label: 'Air' },
{ key: 'rh', label: 'RH' },
{ key: 'dew', label: 'Dew' },
{ key: 'soilT', label: 'Soil °C' },
{ key: 'soilT6', label: 'Soil 6cm' },
{ key: 'soilM', label: 'Soil moist' },
{ key: 'concreteT', label: 'Concrete' },
{ key: 'indoorT', label: 'Indoors' },
{ key: 'managedT', label: 'Managed' },
{ key: 'wind', label: 'Wind' },
{ key: 'dir', label: 'Dir' },
{ key: 'cloud', label: 'Cloud' },
{ key: 'sun', label: 'Sun' },
{ key: 'direct', label: 'Direct' },
{ key: 'diffuse', label: 'Diffuse' },
{ key: 'tmrt', label: 'Tmrt' },
{ key: 'delta', label: 'Δ' },
{ key: 'utci', label: 'UTCI' },
{ key: 'uvA', label: 'UV-A' },
{ key: 'uvB', label: 'UV-B' },
].filter(c =>
activeProfile === 'custom' || !!FILTER_PROFILES[activeProfile].cols[c.key]
).map(c => html`
<button
key=${c.key}
class=${`col-toggle${visibleCols[c.key] ? ' on' : ''}`}
onClick=${() => toggleCol(c.key)}
>${c.label}</button>`)}
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['vehicleT']) && html`
<span class="col-toggle-group">
<select
class=${`col-toggle burn-select grouped-left${visibleCols.vehicleT ? ' on' : ''}`}
value=${visibleCols.vehicleT ? vehicleType : 'off'}
onChange=${(e) => {
if (e.target.value === 'off') {
setVisibleCols(prev => ({ ...prev, vehicleT: false }));
setVehicleVent(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>
${visibleCols.vehicleT && html`
<label
class=${`col-toggle-vent grouped-right${vehicleVent ? ' on' : ''}`}
title="Ventilation — open windows significantly reduce cabin heat build-up">
<input
type="checkbox"
checked=${vehicleVent}
onChange=${() => setVehicleVent(v => !v)}
style=${{ position: 'absolute', opacity: 0, width: 0, height: 0 }} />
<span class="vent-checkbox${vehicleVent ? ' checked' : ''}"></span>
Ventilation
</label>`}
</span>`}
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['indoorT'] || FILTER_PROFILES[activeProfile].cols['managedT']) && html`
<span class="col-toggle-group">
<select
class=${`col-toggle burn-select grouped-left${indoorMode === 'on' ? ' on' : ''}`}
value=${indoorMode === 'on' ? buildingType : 'off'}
onChange=${(e) => {
if (e.target.value === 'off') {
setIndoorMode('off');
setIndoorManaged(false);
} else {
setBuildingType(e.target.value);
setIndoorMode('on');
}
}}>
<option value="off">${indoorMode === 'on' ? 'Hide Indoors' : 'Indoors'}</option>
${Object.entries(BUILDING_TYPES).map(([k, v]) => html`
<option key=${k} value=${k}>${v.name}</option>`)}
</select>
${indoorMode === 'on' && html`
<label
class=${`col-toggle-vent grouped-right${indoorManaged ? ' on' : ''}`}
title="Managed: curtains closed by day, windows open when cooler outside">
<input
type="checkbox"
checked=${indoorManaged}
onChange=${() => setIndoorManaged(v => !v)}
style=${{ position: 'absolute', opacity: 0, width: 0, height: 0 }} />
<span class="vent-checkbox${indoorManaged ? ' checked' : ''}"></span>
Managed
</label>`}
</span>`}
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['precip']) && html`<button class=${`col-toggle${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`}
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['precip']) && html`
<button
class=${`col-toggle${visibleCols.precip ? ' on' : ''}`}
onClick=${() => toggleCol('precip')}
>Precip</button>`}
</div>`}
<!--
@@ -1084,8 +1132,8 @@ export function UTCIForecast() {
${visibleCols.uvB && html`<th class="col-info-th" onClick=${(e) => handleThClick('uvB', e)}>UV-B <span class="col-unit">est. idx</span></th>`}
${visibleCols.burn && html`<th class="col-info-th" onClick=${(e) => handleThClick('burn', e)}>Burn <span class="col-unit">to MED</span></th>`}
${visibleCols.vehicleT && html`<th class="col-info-th" onClick=${(e) => handleThClick('vehicleT', e)}>Vehicle <span class="col-unit">°C peak</span></th>`}
${visibleCols.indoorT && html`<th class="col-info-th" onClick=${(e) => handleThClick('indoorT', e)}>Indoors <span class="col-unit">°C est.</span></th>`}
${visibleCols.managedT && html`<th class="col-info-th" onClick=${(e) => handleThClick('managedT', e)}>Managed <span class="col-unit">°C est.</span></th>`}
${indoorMode === 'on' && !indoorManaged && html`<th class="col-info-th" onClick=${(e) => handleThClick('indoorT', e)}>Indoors <span class="col-unit">°C est.</span></th>`}
${indoorMode === 'on' && indoorManaged && html`<th class="col-info-th" onClick=${(e) => handleThClick('managedT', e)}>Managed <span class="col-unit">°C est.</span></th>`}
${visibleCols.precip && html`<th class="utci-tight-head col-info-th" onClick=${(e) => handleThClick('precip', e)}>Pcpt <span class="col-unit">mm/h</span></th>`}
${visibleCols.utciP && html`<th class="col-info-th" onClick=${(e) => handleThClick('utciP', e)}>UTCI+P <span class="col-unit">°C adj.</span></th>`}
</tr>
@@ -1193,11 +1241,11 @@ export function UTCIForecast() {
<td style=${{ color: r.vehicleT != null && r.vehicleT > 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.vehicleT) }}>
${r.vehicleT != null ? r.vehicleT.toFixed(1) : '—'}
</td>`}
${visibleCols.indoorT && html`
${indoorMode === 'on' && !indoorManaged && html`
<td style=${{ color: r.indoorT != null && r.indoorT > 32 ? '#c0392b' : r.indoorT != null && r.indoorT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBg(r.indoorT) }}>
${r.indoorT != null ? r.indoorT.toFixed(1) : '—'}
</td>`}
${visibleCols.managedT && html`
${indoorMode === 'on' && indoorManaged && html`
<td style=${{ color: r.managedT != null && r.managedT > 32 ? '#c0392b' : r.managedT != null && r.managedT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBg(r.managedT) }}>
${r.managedT != null ? r.managedT.toFixed(1) : '—'}
</td>`}
@@ -1264,32 +1312,28 @@ export function UTCIForecast() {
<div class="utci-about">
<h2 class="utci-about-heading">What is SunScope?</h2>
<p class="utci-about-text">
SunScope shows how the weather will actually <em>feel</em> on your body — not just the air
temperature. It uses the <strong>Universal Thermal Climate Index (UTCI)</strong>, a
peer-reviewed biometeorological standard developed by Bröde et al. (2012) that combines
air temperature, humidity, wind speed, and solar radiation into a single <em>felt
temperature</em>. On a calm, sunny winter day UTCI can read several degrees warmer than
the thermometer; on a grey, blustery day it can read far colder. Forecast data is
sourced in real time from <strong>Open-Meteo</strong>, a free and open-source weather API,
and solar radiation is used to calculate Mean Radiant Temperature — the heat your skin
absorbs from the sun — making SunScope one of the most complete outdoor comfort forecasts
available for free.
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.
</p>
<p class="utci-about-text">
The <strong>UTCI+P</strong> column adds the <strong>SunScope soak-factor</strong>: an
original precipitation penalty that accounts for the extra chill of rain and snow on
exposed skin and wet clothing. Light drizzle reduces the felt temperature by around
1–2 °C; heavy rain combined with wind can push it down by 7–8 °C. Snow carries an
additional penalty on top. The result is an honest, real-world comfort score for any
location worldwide — simply search for your town or city and compare the hourly
forecast across the next 3 days (and up to 14 days with SunScope Extra).
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.
<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.
On clear sunny days this gap can exceed 10 °C even at modest air temperatures.
</div>
</div>