diff --git a/assets/css/table.css b/assets/css/table.css
index 0654ef5..4f98c08 100644
--- a/assets/css/table.css
+++ b/assets/css/table.css
@@ -424,7 +424,81 @@
/* "Tight" header — for columns whose body content is narrower than
the default tracked-out header would suggest (Air, Pcpt). Reducing
the letter-spacing shrinks the heading's natural width so it stops
- driving the column wider than the body needs. Right-aligned values
+ driving the column wider than the body needs. Right-aligned values */
+.utci-table th.utci-tight-head {
+ letter-spacing: 0.04em;
+}
+
+/* ── CLICKABLE COLUMN HEADERS ───────────────────────────────────────────
+ .col-info-th makes every
show a pointer cursor and a subtle hover
+ tint to signal it can be clicked for a description popup. */
+.utci-table th.col-info-th {
+ cursor: pointer;
+ transition: background 0.15s;
+}
+.utci-table th.col-info-th:hover {
+ background: #e8d8a8;
+}
+
+/* ── COLUMN DESCRIPTION POPUP ───────────────────────────────────────────
+ Fixed-position card that appears below a clicked column header.
+ JS sets left/top via inline style (centred on the clicked th). */
+.col-info-popup {
+ position: fixed; /* set by JS — stays in viewport regardless of scroll */
+ z-index: 200;
+ width: 260px;
+ background: #fffcf2;
+ border: 1.5px solid #c9b08a;
+ border-radius: 6px;
+ box-shadow: 0 4px 16px rgba(74,52,32,0.18);
+ padding: 14px 16px 16px;
+ pointer-events: all;
+}
+/* Arrow points DOWN toward the column header */
+.col-info-popup::after {
+ content: '';
+ position: absolute;
+ bottom: -7px;
+ left: var(--arrow-left, 50%);
+ width: 12px;
+ height: 12px;
+ background: #fffcf2;
+ border-right: 1.5px solid #c9b08a;
+ border-bottom: 1.5px solid #c9b08a;
+ transform: rotate(45deg);
+}
+.col-info-title {
+ display: block;
+ font-family: Manrope, sans-serif;
+ font-size: 12px;
+ font-weight: 700;
+ color: #4a3420;
+ margin-bottom: 6px;
+ text-transform: none;
+ letter-spacing: 0;
+}
+.col-info-desc {
+ font-family: Manrope, sans-serif;
+ font-size: 12px;
+ color: #6b4f2a;
+ line-height: 1.5;
+ margin: 0;
+}
+.col-info-close {
+ position: absolute;
+ top: 6px;
+ right: 8px;
+ background: none;
+ border: none;
+ font-size: 16px;
+ color: #a08060;
+ cursor: pointer;
+ line-height: 1;
+ padding: 0 2px;
+}
+.col-info-close:hover {
+ color: #4a3420;
+}
then sit with balanced visual padding on either side. */
.utci-table th.utci-tight-head {
letter-spacing: 0.04em;
diff --git a/assets/js/app.js b/assets/js/app.js
index 32fa94f..ff45076 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -23,7 +23,7 @@
import { h, render, Fragment } from '../vendor/preact.js';
import { useState, useEffect, useLayoutEffect, useRef } from '../vendor/preact-hooks.js';
import htm from '../vendor/htm.js';
-import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcreteTemp, calcVehicleInteriorTemp, calcIndoorTempPass } from './physics.js';
+import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcreteTemp, calcVehicleInteriorTemp, calcIndoorTempPass, calcManagedIndoorTempPass } from './physics.js';
import {
utciCategory, precipPenalty, windCompass8, uvSplit,
SKIN_TYPES, sunburnMinutes, burnLabel,
@@ -123,37 +123,37 @@ export function UTCIForecast() {
basic: {
label: 'Basic',
icon: '🌡️',
- cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false },
+ cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false },
},
urban: {
label: 'Urban',
icon: '🏙️',
- cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false },
+ cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false },
},
farming: {
label: 'Farming',
icon: '🌾',
- cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false },
+ cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false },
},
sailing: {
label: 'Sailing',
icon: '⛵',
- cols: { hour: true, air: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false },
+ cols: { hour: true, air: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false },
},
vehicle: {
label: 'Vehicle',
icon: '🚗',
- cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, indoorT: false },
+ cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: false, precip: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, indoorT: false, managedT: false },
},
home: {
label: 'Home',
icon: '🏠',
- cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true },
+ cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true },
},
custom: {
label: 'Custom',
icon: '⚙️',
- cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false },
+ cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false },
},
};
@@ -170,7 +170,7 @@ export function UTCIForecast() {
tmrt: false, delta: false, utci: false,
uvA: false, uvB: false, burn: false,
utciP: true, precip: true,
- soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false,
+ soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false,
});
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
@@ -190,6 +190,68 @@ export function UTCIForecast() {
const bodyTableRef = useRef(null);
const tableWrapRef = useRef(null);
+ // ─── COLUMN HEADER POPUP ─────────────────────────────────────────────
+ // Clicking a | shows a small description popup below it.
+ // State holds { key, x, y } or null when closed.
+ const [colPopup, setColPopup] = useState(null);
+ const colPopupRef = useRef(null);
+
+ const COL_DESCRIPTIONS = {
+ hour: { title: 'Hour', desc: 'Local wall-clock time for this forecast row. Each row covers one hour.' },
+ air: { title: 'Air Temperature', desc: 'Measured air temperature at 2 m above the ground. This is the standard thermometer reading — it does not account for sun, wind, or humidity.' },
+ rh: { title: 'Relative Humidity', desc: 'How much moisture the air holds relative to its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.' },
+ dew: { title: 'Dew Point', desc: 'The temperature at which air becomes saturated and moisture begins to condense. A useful measure of absolute humidity — above 16 °C it starts to feel muggy; above 21 °C it feels oppressive.' },
+ wind: { title: 'Wind Speed', desc: 'Mean wind speed at 10 m, with peak gust in brackets where significantly higher. Wind dramatically increases heat loss from exposed skin — the basis of wind chill.' },
+ dir: { title: 'Wind Direction', desc: 'The compass direction the wind is blowing from, shown as an arrow and abbreviated label (e.g. SW = south-westerly).' },
+ cloud: { title: 'Cloud Cover', desc: 'Total cloud cover as a percentage of the sky. High cloud blocks solar radiation and reduces both daytime heating and overnight cooling.' },
+ sun: { title: 'Sun Elevation', desc: 'The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the solar radiation reaching the ground.' },
+ direct: { title: 'Direct Radiation', desc: 'Shortwave solar radiation arriving in a direct beam from the sun (W/m²). The primary driver of skin heating on sunny days.' },
+ diffuse: { title: 'Diffuse Radiation', desc: 'Scattered solar radiation arriving from all directions across the sky (W/m²). Present even under cloud; contributes to overall solar load.' },
+ tmrt: { title: 'Mean Radiant Temp', desc: 'The temperature a person\'s skin "sees" from all surrounding surfaces and the sun combined. Can exceed air temperature by 20–30 °C on a sunny day — this is why shade feels so much cooler.' },
+ delta: { title: 'UTCI − Air Delta', desc: 'The difference between the UTCI felt temperature and the plain air temperature. A large positive value means solar radiation is adding significant heat stress beyond what the thermometer shows.' },
+ utci: { title: 'UTCI', desc: 'Universal Thermal Climate Index — the raw felt temperature combining air temp, humidity, wind, and solar radiation. Does not include precipitation effects.' },
+ 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.' },
+ 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.' },
+ soilT: { title: 'Soil Temperature', desc: 'Temperature of the soil at the surface (0 cm depth). Useful for planting decisions — most seeds germinate above 7–10 °C.' },
+ soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.' },
+ soilM: { title: 'Soil Moisture', desc: 'Volumetric water content of the top 1 cm of soil (m³/m³). Values above 0.4 suggest saturated ground; below 0.2 indicates dry conditions.' },
+ concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 15–25 °C above air temperature on sunny days.' },
+ vehicleT: { title: 'Vehicle Interior', desc: 'Estimated peak ambient cabin temperature inside a sealed, parked vehicle. Modelled from solar gain through body panels and side windows. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' },
+ indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a typical UK brick house with windows closed and no air conditioning. Accounts for wall conduction, window solar gain, and thermal mass — indoor temperatures typically peak 2–4 hours after the outdoor peak.' },
+ managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' },
+ };
+
+ // Dismiss popup on click outside
+ useEffect(() => {
+ if (!colPopup) return;
+ const onClickOutside = (e) => {
+ if (colPopupRef.current && !colPopupRef.current.contains(e.target)) {
+ setColPopup(null);
+ }
+ };
+ document.addEventListener('mousedown', onClickOutside);
+ return () => document.removeEventListener('mousedown', onClickOutside);
+ }, [colPopup]);
+
+ const handleThClick = (key, e) => {
+ if (colPopup?.key === key) { setColPopup(null); return; }
+ const rect = e.currentTarget.getBoundingClientRect();
+ const popupW = 260;
+ const margin = 8; // min gap from screen edge
+ // Centre on the th, then clamp so popup stays within viewport
+ let x = rect.left + rect.width / 2;
+ x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin));
+ // Position popup ABOVE the header; arrow points down toward the th
+ const y = rect.top - 6; // 6px gap above the th top edge
+ // Store arrowLeft as offset from popup left edge so arrow stays over the th
+ const popupLeft = x - popupW / 2;
+ const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16));
+ setColPopup({ key, x, y, arrowLeft });
+ };
+
// ─── TABLE SCROLL INDICATORS ─────────────────────────────────────────
// Track whether the body scroller can scroll left/right so we can show
// fade + chevron indicators on the table edges.
@@ -511,7 +573,8 @@ export function UTCIForecast() {
const globArr = hourlyRows.map(r => r.glob);
const elevArr = hourlyRows.map(r => r.elev);
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr);
- hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; });
+ const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr);
+ hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; });
}
// Group those hourly rows into days for the day tabs.
@@ -884,6 +947,7 @@ export function UTCIForecast() {
{ key: 'concreteT', label: 'Concrete' },
{ key: 'vehicleT', label: 'Vehicle' },
{ key: 'indoorT', label: 'Indoors' },
+ { key: 'managedT', label: 'Managed' },
{ key: 'wind', label: 'Wind' },
{ key: 'dir', label: 'Dir' },
{ key: 'cloud', label: 'Cloud' },
@@ -947,30 +1011,31 @@ export function UTCIForecast() {
- | Hour |
- ${visibleCols.air && html`Air °C | `}
- ${visibleCols.rh && html`RH % | `}
- ${visibleCols.dew && html`Dew °C | `}
- ${visibleCols.soilT && html`Soil °C surface | `}
- ${visibleCols.soilT6 && html`Soil 6cm °C root | `}
- ${visibleCols.soilM && html`Soil moist m³/m³ | `}
- ${visibleCols.concreteT && html`Concrete °C surface | `}
- ${visibleCols.vehicleT && html`Vehicle °C peak | `}
- ${visibleCols.indoorT && html`Indoors °C est. | `}
- ${visibleCols.wind && html`Wind m/s (gust) | `}
- ${visibleCols.dir && html`Dir - | `}
- ${visibleCols.cloud && html`Cloud % | `}
- ${visibleCols.sun && html`Sun elev° | `}
- ${visibleCols.direct && html`Direct W/m² | `}
- ${visibleCols.diffuse && html`Diffuse W/m² | `}
- ${visibleCols.tmrt && html`Tmrt °C | `}
- ${visibleCols.delta && html`Δ UTCI−Air | `}
- ${visibleCols.utci && html`UTCI °C felt | `}
- ${visibleCols.uvA && html`UV-A est. idx | `}
- ${visibleCols.uvB && html`UV-B est. idx | `}
- ${visibleCols.burn && html`Burn to MED | `}
- ${visibleCols.precip && html`Pcpt mm/h | `}
- UTCI+P °C adj. |
+ handleThClick('hour', e)}>Hour |
+ ${visibleCols.air && html` handleThClick('air', e)}>Air °C | `}
+ ${visibleCols.rh && html` handleThClick('rh', e)}>RH % | `}
+ ${visibleCols.dew && html` handleThClick('dew', e)}>Dew °C | `}
+ ${visibleCols.soilT && html` handleThClick('soilT', e)}>Soil °C surface | `}
+ ${visibleCols.soilT6 && html` handleThClick('soilT6', e)}>Soil 6cm °C root | `}
+ ${visibleCols.soilM && html` handleThClick('soilM', e)}>Soil moist m³/m³ | `}
+ ${visibleCols.concreteT && html` handleThClick('concreteT', e)}>Concrete °C surface | `}
+ ${visibleCols.wind && html` handleThClick('wind', e)}>Wind m/s (gust) | `}
+ ${visibleCols.dir && html` handleThClick('dir', e)}>Dir - | `}
+ ${visibleCols.cloud && html` handleThClick('cloud', e)}>Cloud % | `}
+ ${visibleCols.sun && html` handleThClick('sun', e)}>Sun elev° | `}
+ ${visibleCols.direct && html` handleThClick('direct', e)}>Direct W/m² | `}
+ ${visibleCols.diffuse && html` handleThClick('diffuse', e)}>Diffuse W/m² | `}
+ ${visibleCols.tmrt && html` handleThClick('tmrt', e)}>Tmrt °C | `}
+ ${visibleCols.delta && html` handleThClick('delta', e)}>Δ UTCI−Air | `}
+ ${visibleCols.utci && html` handleThClick('utci', e)}>UTCI °C felt | `}
+ ${visibleCols.uvA && html` handleThClick('uvA', e)}>UV-A est. idx | `}
+ ${visibleCols.uvB && html` handleThClick('uvB', e)}>UV-B est. idx | `}
+ ${visibleCols.burn && html` handleThClick('burn', e)}>Burn to MED | `}
+ ${visibleCols.vehicleT && html` handleThClick('vehicleT', e)}>Vehicle °C peak | `}
+ ${visibleCols.indoorT && html` handleThClick('indoorT', e)}>Indoors °C est. | `}
+ ${visibleCols.managedT && html` handleThClick('managedT', e)}>Managed °C est. | `}
+ ${visibleCols.precip && html` handleThClick('precip', e)}>Pcpt mm/h | `}
+ ${visibleCols.utciP && html` handleThClick('utciP', e)}>UTCI+P °C adj. | `}
@@ -988,6 +1053,19 @@ export function UTCIForecast() {
const isNow = r.iso.slice(0, 13) === nowLocalISO;
const delta = r.utci - r.Ta;
const adjCat = utciCategory(r.utciAdj);
+ // Returns a very light background tint based on temperature value.
+ // Cold → faint blue, cool → faint green, warm → faint amber, hot → faint red.
+ const tempBg = (t) => {
+ if (t == null) return 'transparent';
+ if (t < 0) return 'rgba(100,160,230,0.10)';
+ if (t < 10) return 'rgba(140,200,200,0.10)';
+ if (t < 18) return 'rgba(140,200,150,0.10)';
+ if (t < 26) return 'rgba(100,190,100,0.10)';
+ if (t < 32) return 'rgba(230,200, 80,0.10)';
+ if (t < 40) return 'rgba(230,140, 50,0.10)';
+ if (t < 50) return 'rgba(210, 70, 50,0.10)';
+ return 'rgba(160, 30, 30,0.10)';
+ };
// r.iso is the local wall-clock string from the API — slice it directly.
const h24 = parseInt(r.iso.slice(11, 13), 10);
const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`;
@@ -1000,27 +1078,19 @@ export function UTCIForecast() {
${localHHMM}
- ${visibleCols.air && html` | ${r.Ta.toFixed(1)} | `}
+ ${visibleCols.air && html`${r.Ta.toFixed(1)} | `}
${visibleCols.rh && html`${Math.round(r.RH)} | `}
${visibleCols.dew && html`${r.dew != null ? r.dew.toFixed(1) : '—'} | `}
${visibleCols.soilT && html`
- ${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'} | `}
+ ${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'} | `}
${visibleCols.soilT6 && html`
${r.soilT6 != null ? r.soilT6.toFixed(1) : '—'} | `}
${visibleCols.soilM && html`
${r.soilM != null ? r.soilM.toFixed(3) : '—'} | `}
${visibleCols.concreteT && html`
- 40 ? '#c0392b' : r.concreteT != null && r.concreteT > 30 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold' }}>
+ | 40 ? '#c0392b' : r.concreteT != null && r.concreteT > 30 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.concreteT) }}>
${r.concreteT != null ? r.concreteT.toFixed(1) : '—'}
| `}
- ${visibleCols.vehicleT && html`
- 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold' }}>
- ${r.vehicleT != null ? r.vehicleT.toFixed(1) : '—'}
- | `}
- ${visibleCols.indoorT && html`
- 32 ? '#c0392b' : r.indoorT != null && r.indoorT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold' }}>
- ${r.indoorT != null ? r.indoorT.toFixed(1) : '—'}
- | `}
${visibleCols.wind && html`
${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5
? html`(${r.gust.toFixed(1)})`
@@ -1067,6 +1137,18 @@ export function UTCIForecast() {
| 0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#4a3218' }}>
${burnLabel(sunburnMinutes(r.uv, skinType))}
| `}
+ ${visibleCols.vehicleT && html`
+ 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.vehicleT) }}>
+ ${r.vehicleT != null ? r.vehicleT.toFixed(1) : '—'}
+ | `}
+ ${visibleCols.indoorT && html`
+ 32 ? '#c0392b' : r.indoorT != null && r.indoorT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBg(r.indoorT) }}>
+ ${r.indoorT != null ? r.indoorT.toFixed(1) : '—'}
+ | `}
+ ${visibleCols.managedT && html`
+ 32 ? '#c0392b' : r.managedT != null && r.managedT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBg(r.managedT) }}>
+ ${r.managedT != null ? r.managedT.toFixed(1) : '—'}
+ | `}
${visibleCols.precip && html`
@@ -1076,11 +1158,12 @@ export function UTCIForecast() {
| `}
-
-
- ${r.utciAdj.toFixed(1)}
-
- |
+ ${visibleCols.utciP && html`
+
+
+ ${r.utciAdj.toFixed(1)}
+
+ | `}
`;
})}
@@ -1088,6 +1171,23 @@ export function UTCIForecast() {
+ ${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
+ `}
+
Thermal stress bands
diff --git a/assets/js/physics.js b/assets/js/physics.js
index e12691b..429aaf3 100644
--- a/assets/js/physics.js
+++ b/assets/js/physics.js
@@ -165,6 +165,73 @@ export function calcIndoorTempPass(TaArr, globArr, elevArr) {
return result;
}
+// ═══════════════════════════════════════════════════════════════════
+// UK HOUSE INDOOR TEMPERATURE — MANAGED (curtains closed, windows open)
+// ───────────────────────────────────────────────────────────────────
+// Models the same UK brick house as calcIndoorTempPass but with two
+// behavioural interventions that reflect standard heatwave advice:
+//
+// 1. CURTAINS CLOSED
+// Thick curtains block ~80% of window solar gain before it enters
+// the room. The small remaining fraction is diffuse light through
+// the curtain fabric. Wall conduction is unchanged.
+//
+// 2. WINDOWS OPEN (smart ventilation)
+// When outdoor air is cooler than the indoor air, windows are open
+// and a ventilation heat exchange pulls the indoor temp toward
+// outdoor. When outdoor is hotter than indoor, windows are kept
+// shut — so this strategy never makes things worse, only better.
+// Ventilation rate ~2 air changes/hour for a well-opened house.
+//
+// Same thermal lag model as calcIndoorTempPass (4 h brick time constant).
+// ═══════════════════════════════════════════════════════════════════
+export function calcManagedIndoorTempPass(TaArr, globArr, elevArr) {
+ 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;
+
+ let Ti = TaArr[0] ?? 15;
+
+ for (let i = 0; i < n; i++) {
+ 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);
+
+ // Wall conduction (unchanged)
+ const conductionGain = uWall * (Ta - Ti);
+
+ const heatCapProxy = 0.15;
+ const Ti_instant = Ti + (conductionGain + solarGain) * heatCapProxy;
+
+ // Apply thermal lag
+ Ti = Ti + alpha * (Ti_instant - Ti);
+
+ // Smart ventilation: only open windows when outside is cooler
+ if (Ta < Ti) {
+ Ti = Ti + ventAlpha * (Ta - Ti);
+ }
+
+ result[i] = Math.max(Math.min(Ti, 55), Math.min(Ta, Ti));
+ }
+
+ return result;
+}
+
// ═══════════════════════════════════════════════════════════════════
// VEHICLE INTERIOR CABIN TEMPERATURE (seated occupant, not in sunbeam)
// ───────────────────────────────────────────────────────────────────