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
12 °C; heavy rain combined with wind can push it down by 78 °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>