New columns and title popups
Click scroll
This commit is contained in:
fraxle
2026-05-15 00:04:58 +01:00
parent e0712300e5
commit 780bffaeeb
3 changed files with 292 additions and 51 deletions
+150 -50
View File
@@ -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 <th> 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 2030 °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 710 °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 1525 °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 24 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() {
<table class="utci-table utci-table-head" ref=${headTableRef}>
<thead>
<tr>
<th>Hour</th>
${visibleCols.air && html`<th class="utci-tight-head">Air <span class="col-unit">°C</span></th>`}
${visibleCols.rh && html`<th>RH <span class="col-unit">%</span></th>`}
${visibleCols.dew && html`<th>Dew <span class="col-unit">°C</span></th>`}
${visibleCols.soilT && html`<th>Soil °C <span class="col-unit">surface</span></th>`}
${visibleCols.soilT6 && html`<th>Soil 6cm <span class="col-unit">°C root</span></th>`}
${visibleCols.soilM && html`<th>Soil moist <span class="col-unit">m³/m³</span></th>`}
${visibleCols.concreteT && html`<th>Concrete <span class="col-unit">°C surface</span></th>`}
${visibleCols.vehicleT && html`<th>Vehicle <span class="col-unit">°C peak</span></th>`}
${visibleCols.indoorT && html`<th>Indoors <span class="col-unit">°C est.</span></th>`}
${visibleCols.wind && html`<th>Wind <span class="col-unit">m/s (gust)</span></th>`}
${visibleCols.dir && html`<th class="utci-dir-cell">Dir <span class="col-unit">-</span></th>`}
${visibleCols.cloud && html`<th>Cloud <span class="col-unit">%</span></th>`}
${visibleCols.sun && html`<th>Sun <span class="col-unit">elev°</span></th>`}
${visibleCols.direct && html`<th>Direct <span class="col-unit">W/m²</span></th>`}
${visibleCols.diffuse && html`<th>Diffuse <span class="col-unit">W/m²</span></th>`}
${visibleCols.tmrt && html`<th>Tmrt <span class="col-unit">°C</span></th>`}
${visibleCols.delta && html`<th <span class="col-unit">UTCIAir</span></th>`}
${visibleCols.utci && html`<th>UTCI <span class="col-unit">°C felt</span></th>`}
${visibleCols.uvA && html`<th>UV-A <span class="col-unit">est. idx</span></th>`}
${visibleCols.uvB && html`<th>UV-B <span class="col-unit">est. idx</span></th>`}
${visibleCols.burn && html`<th>Burn <span class="col-unit">to MED</span></th>`}
${visibleCols.precip && html`<th class="utci-tight-head">Pcpt <span class="col-unit">mm/h</span></th>`}
<th>UTCI+P <span class="col-unit">°C adj.</span></th>
<th class="col-info-th" onClick=${(e) => handleThClick('hour', e)}>Hour</th>
${visibleCols.air && html`<th class="utci-tight-head col-info-th" onClick=${(e) => handleThClick('air', e)}>Air <span class="col-unit">°C</span></th>`}
${visibleCols.rh && html`<th class="col-info-th" onClick=${(e) => handleThClick('rh', e)}>RH <span class="col-unit">%</span></th>`}
${visibleCols.dew && html`<th class="col-info-th" onClick=${(e) => handleThClick('dew', e)}>Dew <span class="col-unit">°C</span></th>`}
${visibleCols.soilT && html`<th class="col-info-th" onClick=${(e) => handleThClick('soilT', e)}>Soil °C <span class="col-unit">surface</span></th>`}
${visibleCols.soilT6 && html`<th class="col-info-th" onClick=${(e) => handleThClick('soilT6', e)}>Soil 6cm <span class="col-unit">°C root</span></th>`}
${visibleCols.soilM && html`<th class="col-info-th" onClick=${(e) => handleThClick('soilM', e)}>Soil moist <span class="col-unit">m³/m³</span></th>`}
${visibleCols.concreteT && html`<th class="col-info-th" onClick=${(e) => handleThClick('concreteT', e)}>Concrete <span class="col-unit">°C surface</span></th>`}
${visibleCols.wind && html`<th class="col-info-th" onClick=${(e) => handleThClick('wind', e)}>Wind <span class="col-unit">m/s (gust)</span></th>`}
${visibleCols.dir && html`<th class="utci-dir-cell col-info-th" onClick=${(e) => handleThClick('dir', e)}>Dir <span class="col-unit">-</span></th>`}
${visibleCols.cloud && html`<th class="col-info-th" onClick=${(e) => handleThClick('cloud', e)}>Cloud <span class="col-unit">%</span></th>`}
${visibleCols.sun && html`<th class="col-info-th" onClick=${(e) => handleThClick('sun', e)}>Sun <span class="col-unit">elev°</span></th>`}
${visibleCols.direct && html`<th class="col-info-th" onClick=${(e) => handleThClick('direct', e)}>Direct <span class="col-unit">W/m²</span></th>`}
${visibleCols.diffuse && html`<th class="col-info-th" onClick=${(e) => handleThClick('diffuse', e)}>Diffuse <span class="col-unit">W/m²</span></th>`}
${visibleCols.tmrt && html`<th class="col-info-th" onClick=${(e) => handleThClick('tmrt', e)}>Tmrt <span class="col-unit">°C</span></th>`}
${visibleCols.delta && html`<th class="col-info-th" onClick=${(e) => handleThClick('delta', e)}>Δ <span class="col-unit">UTCIAir</span></th>`}
${visibleCols.utci && html`<th class="col-info-th" onClick=${(e) => handleThClick('utci', e)}>UTCI <span class="col-unit">°C felt</span></th>`}
${visibleCols.uvA && html`<th class="col-info-th" onClick=${(e) => handleThClick('uvA', e)}>UV-A <span class="col-unit">est. idx</span></th>`}
${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>`}
${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>
</thead>
</table>
@@ -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() {
<span>${localHHMM}</span>
</span>
</td>
${visibleCols.air && html`<td>${r.Ta.toFixed(1)}</td>`}
${visibleCols.air && html`<td style=${{ background: tempBg(r.Ta) }}>${r.Ta.toFixed(1)}</td>`}
${visibleCols.rh && html`<td>${Math.round(r.RH)}</td>`}
${visibleCols.dew && html`<td>${r.dew != null ? r.dew.toFixed(1) : ''}</td>`}
${visibleCols.soilT && html`
<td style=${{ color: '#6b4a1c' }}>${r.soilT0 != null ? r.soilT0.toFixed(1) : ''}</td>`}
<td style=${{ color: '#6b4a1c', background: tempBg(r.soilT0) }}>${r.soilT0 != null ? r.soilT0.toFixed(1) : ''}</td>`}
${visibleCols.soilT6 && html`
<td style=${{ color: '#6b4a1c' }}>${r.soilT6 != null ? r.soilT6.toFixed(1) : ''}</td>`}
${visibleCols.soilM && html`
<td style=${{ color: '#2a6a90' }}>${r.soilM != null ? r.soilM.toFixed(3) : ''}</td>`}
${visibleCols.concreteT && html`
<td style=${{ color: r.concreteT != null && r.concreteT > 40 ? '#c0392b' : r.concreteT != null && r.concreteT > 30 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold' }}>
<td style=${{ color: r.concreteT != null && r.concreteT > 40 ? '#c0392b' : r.concreteT != null && r.concreteT > 30 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.concreteT) }}>
${r.concreteT != null ? r.concreteT.toFixed(1) : ''}
</td>`}
${visibleCols.vehicleT && html`
<td style=${{ color: r.vehicleT != null && r.vehicleT > 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold' }}>
${r.vehicleT != null ? r.vehicleT.toFixed(1) : ''}
</td>`}
${visibleCols.indoorT && html`
<td style=${{ color: r.indoorT != null && r.indoorT > 32 ? '#c0392b' : r.indoorT != null && r.indoorT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold' }}>
${r.indoorT != null ? r.indoorT.toFixed(1) : ''}
</td>`}
${visibleCols.wind && html`<td>
${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5
? html`<span style=${{ opacity: 0.65, marginLeft: '4px' }}>(${r.gust.toFixed(1)})</span>`
@@ -1067,6 +1137,18 @@ export function UTCIForecast() {
<td style=${{ color: r.uv > 0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#4a3218' }}>
${burnLabel(sunburnMinutes(r.uv, skinType))}
</td>`}
${visibleCols.vehicleT && html`
<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`
<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`
<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>`}
${visibleCols.precip && html`
<td>
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '5px', verticalAlign: 'middle' }}>
@@ -1076,11 +1158,12 @@ export function UTCIForecast() {
</span>
</span>
</td>`}
<td style=${{ background: 'rgba(180,215,250,0.10)' }}>
<span class="utci-cell utci-cell-hero" style=${{ background: adjCat.bg, color: adjCat.fg }}>
${r.utciAdj.toFixed(1)}
</span>
</td>
${visibleCols.utciP && html`
<td style=${{ background: 'rgba(180,215,250,0.10)' }}>
<span class="utci-cell utci-cell-hero" style=${{ background: adjCat.bg, color: adjCat.fg }}>
${r.utciAdj.toFixed(1)}
</span>
</td>`}
</tr>`;
})}
</tbody>
@@ -1088,6 +1171,23 @@ export function UTCIForecast() {
</div>
</div>
${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
<div
ref=${colPopupRef}
class="col-info-popup"
style=${{
position: 'fixed',
left: `${colPopup.x}px`,
top: `${colPopup.y}px`,
transform: 'translateX(-50%) translateY(-100%)',
'--arrow-left': `${colPopup.arrowLeft}px`,
}}
>
<button class="col-info-close" onClick=${() => setColPopup(null)} aria-label="Close">×</button>
<strong class="col-info-title">${COL_DESCRIPTIONS[colPopup.key].title}</strong>
<p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].desc}</p>
</div>`}
<div class="utci-legend">
<span class="utci-legend-label">Thermal stress bands</span>
<div class="utci-legend-row">