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
+38 -3
View File
@@ -115,21 +115,56 @@ em { color: #7a5c2a; font-style: italic; }
background: #fef8ea;
border: 1px solid #e0d0a8;
border-radius: 8px;
padding: 1.2rem 1.4rem;
margin-bottom: 1rem;
margin-bottom: 0.5rem;
overflow: hidden;
transition: border-color 0.15s;
}
.faq-block[open] {
border-color: #c8922a;
}
.faq-q {
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.2rem;
font-weight: 600;
color: #1e1208;
margin-bottom: 0.4rem;
font-size: 0.97rem;
cursor: pointer;
margin: 0;
user-select: none;
}
.faq-q::-webkit-details-marker { display: none; }
.faq-q::after {
content: '+';
font-size: 1.3rem;
font-weight: 300;
color: #c8922a;
flex-shrink: 0;
line-height: 1;
transition: transform 0.2s;
}
.faq-block[open] .faq-q::after {
transform: rotate(45deg);
}
.faq-block[open] .faq-q {
border-bottom: 1px solid #e0d0a8;
}
.faq-a {
color: #5a3e1a;
font-size: 0.93rem;
margin: 0;
padding: 0.9rem 1.2rem 1rem;
line-height: 1.65;
}
.stress-table {
+75
View File
@@ -238,6 +238,81 @@
border-color: #c8922a;
}
/* ── Vehicle group: select + windows-open checkbox fused together ── */
.col-toggle-group {
display: inline-flex;
align-items: stretch;
gap: 0;
}
/* Remove the right border + radius on the select so it butts up flush
with the checkbox label. When the label is absent (hide state), the
select is the only child and :last-child restores full pill rounding. */
.col-toggle-group .grouped-left {
border-right: none;
border-radius: 20px 0 0 20px;
}
.col-toggle-group .grouped-left:last-child {
border-right: 1.5px solid #c9b08a;
border-radius: 20px;
}
/* The checkbox pill — same height/padding as a col-toggle but styled as a label */
.col-toggle-vent {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px 4px 9px;
font-size: 9px;
font-family: JetBrains Mono, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
border: 1.5px solid #c9b08a;
border-left: 1px solid #d4c0a0; /* softer join line */
border-radius: 0 20px 20px 0;
background: #fef8ee;
color: #7a5c30;
user-select: none;
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.col-toggle-vent:hover {
border-color: #c8922a;
color: #6b4f2a;
background: #fef3dc;
}
.col-toggle-vent.on {
background: #c8922a;
border-color: #c8922a;
color: #fff;
}
/* Custom checkbox square inside the vent label */
.vent-checkbox {
display: inline-block;
width: 12px;
height: 12px;
border: 1.5px solid currentColor;
border-radius: 3px;
background: transparent;
flex-shrink: 0;
position: relative;
transition: background 0.12s;
}
.vent-checkbox.checked::after {
content: '';
position: absolute;
left: 2px;
top: -1px;
width: 5px;
height: 8px;
border: 2px solid currentColor;
border-top: none;
border-left: none;
transform: rotate(45deg);
}
/* ── 7. HOURLY TABLE ────────────────────────────────────────────────── */
+12
View File
@@ -99,6 +99,18 @@
color: #6b4f2a;
}
.utci-about-link {
display: inline-block;
margin-left: 4px;
color: #c8922a;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
.utci-about-link:hover {
text-decoration: underline;
}
.utci-footer {
margin-top: 36px;
padding-top: 20px;
+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>
+32 -46
View File
@@ -7,13 +7,15 @@
// 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)
// calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType, ventilated)
// calcIndoorTempPass(TaArr, globArr, elevArr, buildingType)
// calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType)
//
// 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';
import { VEHICLE_TYPES, BUILDING_TYPES } from './utils.js';
// ═══════════════════════════════════════════════════════════════════
// PHYSICAL CONSTANTS
@@ -120,48 +122,37 @@ function _houseHeatLoad(Ta, globalRad, solElev) {
// Two-pass function: call with the full arrays of hourly Ta and globalRad.
// Returns an array of indoor temperatures, one per hour.
export function calcIndoorTempPass(TaArr, globArr, elevArr) {
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
export function calcIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick') {
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
const { uWall, lagHours, glazingRatio, gValue, orientFactor } = preset;
const n = TaArr.length;
const result = new Array(n);
// Thermal resistance of the building envelope (°C per W/m² of heat load)
// Lower = faster response to outdoor changes. UK brick mid-stock ~0.45.
const uWall = 0.35; // W/m²K effective wall U-value
// Thermal mass time constant: heavier = longer lag.
// ~4 h lag for typical UK brick semi (expressed as exponential decay weight).
const lagHours = 4;
const alpha = 1 - Math.exp(-1 / lagHours); // per-hour blending weight
const alpha = 1 - Math.exp(-1 / lagHours); // per-hour blending weight
// Seed indoor temp to first outdoor temp
let Ti = TaArr[0] ?? 15;
for (let i = 0; i < n; i++) {
const Ta = TaArr[i] ?? Ti;
const glob = globArr[i] ?? 0;
const solElev = elevArr[i] ?? 0;
const Ta = TaArr[i] ?? Ti;
const glob = globArr[i] ?? 0;
// Solar gain through windows (W/m² effective)
const glazingRatio = 0.16;
const gValue = 0.63;
const orientFactor = 0.50;
const solarGain = glob * glazingRatio * gValue * orientFactor;
const solarGain = glob * glazingRatio * gValue * orientFactor;
// Conductive heat flow through walls: proportional to (Ta - Ti)
// uWall drives how quickly the indoor temp chases outdoor temp.
const conductionGain = uWall * (Ta - Ti);
// Target indoor temp this hour if there were no thermal mass:
// Ti_instant = Ti + conduction + solar load / heat capacity proxy
// heatCap proxy: how many degrees does 1 W/m² raise the indoor air?
// For a typical 90 m² house, ~0.15 °C per W/m² effective.
// Heat capacity proxy: how many °C does 1 W/m² raise the indoor air?
// Typical 90 m² house ≈ 0.15; conservatory much lower (less thermal mass).
const heatCapProxy = 0.15;
const Ti_instant = Ti + (conductionGain + solarGain) * heatCapProxy;
// Apply thermal lag: blend toward Ti_instant slowly
// Apply thermal lag: blend toward Ti_instant at the building's time constant
Ti = Ti + alpha * (Ti_instant - Ti);
// Can't be colder than outdoor (house doesn't actively cool)
// Cap at 55 °C (physically implausible above this for a house interior)
result[i] = Math.max(Math.min(Ti, 55), Math.min(Ta, Ti));
}
@@ -188,20 +179,14 @@ export function calcIndoorTempPass(TaArr, globArr, elevArr) {
//
// Same thermal lag model as calcIndoorTempPass (4 h brick time constant).
// ═══════════════════════════════════════════════════════════════════
export function calcManagedIndoorTempPass(TaArr, globArr, elevArr) {
// buildingType must be a key of BUILDING_TYPES; defaults to 'brick'.
export function calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType = 'brick') {
const preset = BUILDING_TYPES[buildingType] || BUILDING_TYPES.brick;
const { uWall, lagHours, glazingRatio, gValue, orientFactor, curtainBlock, ventAlpha } = preset;
const n = TaArr.length;
const result = new Array(n);
const uWall = 0.35;
const lagHours = 4;
const alpha = 1 - Math.exp(-1 / lagHours);
// Curtain factor: blocks 80% of solar gain
const curtainBlock = 0.80;
// Ventilation blending weight per hour when windows are open
// ~2 ACH for a well-ventilated house → meaningful but not instant pull
const ventAlpha = 0.25;
const alpha = 1 - Math.exp(-1 / lagHours);
let Ti = TaArr[0] ?? 15;
@@ -209,13 +194,10 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr) {
const Ta = TaArr[i] ?? Ti;
const glob = globArr[i] ?? 0;
// Solar gain — curtains block 80%
const glazingRatio = 0.16;
const gValue = 0.63;
const orientFactor = 0.50;
const solarGain = glob * glazingRatio * gValue * orientFactor * (1 - curtainBlock);
// Solar gain — curtains block curtainBlock fraction
const solarGain = glob * glazingRatio * gValue * orientFactor * (1 - curtainBlock);
// Wall conduction (unchanged)
// Wall conduction (unchanged from unmanaged model)
const conductionGain = uWall * (Ta - Ti);
const heatCapProxy = 0.15;
@@ -265,7 +247,7 @@ export function calcManagedIndoorTempPass(TaArr, globArr, elevArr) {
// 3545 °C — dangerous for children/pets (hyperthermia risk)
// > 45 °C — potentially fatal within minutes
// ═══════════════════════════════════════════════════════════════════
export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'car') {
export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'car', ventilated = false) {
if (globalRad == null || Ta == null) return null;
// Look up vehicle preset; fall back to a standard car if key unknown.
@@ -303,8 +285,12 @@ export function calcVehicleInteriorTemp(Ta, globalRad, solElev, vehicleType = 'c
// ── Combine into cabin air temperature ───────────────────────────
// Total heat input per m² of cabin surface
const totalGain = conductionGain + glazingGain;
// Cabin heat loss: varies by vehicle type (motorhomes insulated, cars not)
const Ti = Ta + totalGain / preset.hCabinLoss;
// Cabin heat loss: varies by vehicle type (motorhomes insulated, cars not).
// With windows open, convective loss is roughly 5× higher — air moves freely
// through the cabin, flushing heat out and capping interior temperature much
// closer to ambient. Cabin temp still rises a little due to panel/roof solar gain.
const effectiveHLoss = ventilated ? preset.hCabinLoss * 5 : preset.hCabinLoss;
const Ti = Ta + totalGain / effectiveHLoss;
// Clamp: can't be cooler than outside air; physical cap at 90 °C
return Math.max(Ta, Math.min(Ti, 90));
+31
View File
@@ -10,6 +10,7 @@
// sunburnMinutes(uv, skinType) minutes to MED
// burnLabel(mins) formats burn time as "12m"/"1.5h"
// VEHICLE_TYPES vehicle presets for cabin heat model
// BUILDING_TYPES building presets for indoor heat model
// cloudCategory(total,low,mid,high) → 'clear'|'wispy'|'scattered'|'overcast'
// confidenceBand(i) day-tab gradient + label
// moonPhaseFraction(date) 0..1 synodic phase
@@ -140,6 +141,36 @@ export const VEHICLE_TYPES = {
caravan: { name: 'Caravan (towed)', albedo: 0.40, glazingArea: 0.4, hCabinLoss: 1.0 },
};
// ═══════════════════════════════════════════════════════════════════
// BUILDING TYPES — presets for the indoor temperature model.
// ───────────────────────────────────────────────────────────────────
// Each preset drives both calcIndoorTempPass and calcManagedIndoorTempPass.
//
// uWall W/m²K Effective envelope U-value. Higher = faster response
// to outdoor swings, less insulation.
// lagHours h Thermal mass time constant. Heavier construction = longer
// lag before indoor temp follows outdoor changes.
// glazingRatio — Fraction of floor area that is window. More glass = more
// solar gain in summer, more heat loss in winter.
// gValue — Solar heat gain coefficient of glazing. 0.63 = standard
// double glazing; 0.3 = modern low-e triple.
// orientFactor — Fraction of windows facing the sun at any given time.
// 0.5 = random orientation; 0.8 = south-facing conservatory.
// curtainBlock — Fraction of solar gain blocked when managed (curtains
// closed). Thick lined curtains ≈ 0.80; blinds ≈ 0.50.
// ventAlpha — Blending weight per hour when smart ventilation is open.
// Higher = more air changes per hour.
// ═══════════════════════════════════════════════════════════════════
export const BUILDING_TYPES = {
brick: { name: 'Brick (typical)', uWall: 0.35, lagHours: 4, glazingRatio: 0.16, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.25 },
modern: { name: 'Modern / Well insulated', uWall: 0.18, lagHours: 5, glazingRatio: 0.20, gValue: 0.30, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.20 },
victorian: { name: 'Victorian Terrace', uWall: 0.55, lagHours: 5, glazingRatio: 0.12, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.30 },
stone: { name: 'Stone / Granite Cottage', uWall: 0.45, lagHours: 7, glazingRatio: 0.10, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.25 },
timber: { name: 'Timber Frame / New Build', uWall: 0.22, lagHours: 2, glazingRatio: 0.22, gValue: 0.35, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.35 },
flat: { name: 'Top-floor Flat', uWall: 0.40, lagHours: 3, glazingRatio: 0.18, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.20 },
conservatory:{ name: 'Conservatory / Sun Room', uWall: 1.20, lagHours: 1, glazingRatio: 0.70, gValue: 0.72, orientFactor: 0.70, curtainBlock: 0.50, ventAlpha: 0.50 },
};
// ═══════════════════════════════════════════════════════════════════
// CLOUD CATEGORY — pick one of 4 icon styles from low/mid/high split.
// ───────────────────────────────────────────────────────────────────