From 780bffaeeb546139d749b57061418d83d436887c Mon Sep 17 00:00:00 2001 From: fraxle Date: Fri, 15 May 2026 00:04:58 +0100 Subject: [PATCH] Beta 7 New columns and title popups Click scroll --- assets/css/table.css | 76 +++++++++++++++- assets/js/app.js | 200 ++++++++++++++++++++++++++++++++----------- assets/js/physics.js | 67 +++++++++++++++ 3 files changed, 292 insertions(+), 51 deletions(-) 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() { - - ${visibleCols.air && html``} - ${visibleCols.rh && html``} - ${visibleCols.dew && html``} - ${visibleCols.soilT && html``} - ${visibleCols.soilT6 && html``} - ${visibleCols.soilM && html``} - ${visibleCols.concreteT && html``} - ${visibleCols.vehicleT && html``} - ${visibleCols.indoorT && html``} - ${visibleCols.wind && html``} - ${visibleCols.dir && html``} - ${visibleCols.cloud && html``} - ${visibleCols.sun && html``} - ${visibleCols.direct && html``} - ${visibleCols.diffuse && html``} - ${visibleCols.tmrt && html``} - ${visibleCols.delta && html``} - ${visibleCols.utci && html``} - ${visibleCols.uvA && html``} - ${visibleCols.uvB && html``} - ${visibleCols.burn && html``} - ${visibleCols.precip && html``} - + + ${visibleCols.air && html``} + ${visibleCols.rh && html``} + ${visibleCols.dew && html``} + ${visibleCols.soilT && html``} + ${visibleCols.soilT6 && html``} + ${visibleCols.soilM && html``} + ${visibleCols.concreteT && html``} + ${visibleCols.wind && html``} + ${visibleCols.dir && html``} + ${visibleCols.cloud && html``} + ${visibleCols.sun && html``} + ${visibleCols.direct && html``} + ${visibleCols.diffuse && html``} + ${visibleCols.tmrt && html``} + ${visibleCols.delta && html``} + ${visibleCols.utci && html``} + ${visibleCols.uvA && html``} + ${visibleCols.uvB && html``} + ${visibleCols.burn && html``} + ${visibleCols.vehicleT && html``} + ${visibleCols.indoorT && html``} + ${visibleCols.managedT && html``} + ${visibleCols.precip && html``} + ${visibleCols.utciP && html``}
HourAir °CRH %Dew °CSoil °C surfaceSoil 6cm °C rootSoil moist m³/m³Concrete °C surfaceVehicle °C peakIndoors °C est.Wind m/s (gust)Dir -Cloud %Sun elev°Direct W/m²Diffuse W/m²Tmrt °CΔ UTCI−AirUTCI °C feltUV-A est. idxUV-B est. idxBurn to MEDPcpt mm/hUTCI+P °C adj. handleThClick('hour', e)}>Hour handleThClick('air', e)}>Air °C handleThClick('rh', e)}>RH % handleThClick('dew', e)}>Dew °C handleThClick('soilT', e)}>Soil °C surface handleThClick('soilT6', e)}>Soil 6cm °C root handleThClick('soilM', e)}>Soil moist m³/m³ handleThClick('concreteT', e)}>Concrete °C surface handleThClick('wind', e)}>Wind m/s (gust) handleThClick('dir', e)}>Dir - handleThClick('cloud', e)}>Cloud % handleThClick('sun', e)}>Sun elev° handleThClick('direct', e)}>Direct W/m² handleThClick('diffuse', e)}>Diffuse W/m² handleThClick('tmrt', e)}>Tmrt °C handleThClick('delta', e)}>Δ UTCI−Air handleThClick('utci', e)}>UTCI °C felt handleThClick('uvA', e)}>UV-A est. idx handleThClick('uvB', e)}>UV-B est. idx handleThClick('burn', e)}>Burn to MED handleThClick('vehicleT', e)}>Vehicle °C peak handleThClick('indoorT', e)}>Indoors °C est. handleThClick('managedT', e)}>Managed °C est. handleThClick('precip', e)}>Pcpt mm/h 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` +
+ + ${COL_DESCRIPTIONS[colPopup.key].title} +

${COL_DESCRIPTIONS[colPopup.key].desc}

+
`} +
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) // ───────────────────────────────────────────────────────────────────