Layout tweaks
This commit is contained in:
fraxle
2026-08-06 00:22:20 +01:00
parent 0d166e2b80
commit 7f3fbb18c7
5 changed files with 699 additions and 417 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
.utci-loc-search {
position: relative;
margin-top: 18px;
width: 300px;
width: 380px;
max-width: 100%;
}
+171 -411
View File
@@ -39,6 +39,7 @@ import { DayTabs } from './components/DayTabs.js';
import { ConfigPanel } from './components/ConfigPanel.js';
import { WelcomeModal } from './components/WelcomeModal.js';
import { RestoreModal } from './components/RestoreModal.js';
import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js';
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay } from './compute.js';
import { solarElevationDeg } from './physics.js';
import { exportDayXls } from './export.js';
@@ -232,6 +233,7 @@ export function UTCIForecast() {
indoorMode, setIndoorMode,
pollenType, setPollenTypeAndSave,
utciEnv, setUtciEnv,
tableRotated, toggleTableRotated,
headStickyRef, headTrackRef, headTableRef,
bodyScrollRef, bodyTableRef, tableWrapRef,
colPopup, colPopupRef,
@@ -435,6 +437,9 @@ export function UTCIForecast() {
const g = GROUP_OF[key];
return g ? `grp-${g}` : '';
};
// Display names for the column groups — used for the spanning labels
// above the columns normally, and for the divider rows when rotated.
const GROUP_LABELS = { felt: 'Felt', surface: 'Surface', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' };
// Builds the group label row above the column headers.
// Each visible group gets one spanning cell; groups with no visible
// columns are skipped entirely. Hour always gets a blank lead cell.
@@ -444,84 +449,14 @@ export function UTCIForecast() {
for (const g of groups) {
const span = GROUP_ORDER[g].filter(isColVisible).length;
if (span === 0) continue;
const labels = { felt: 'Felt', surface: 'Surface', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' };
cells.push(html`<th class=${`grp-label grp-label-${g} grp-${g}`} colspan=${span} scope="colgroup">${labels[g]}</th>`);
cells.push(html`<th class=${`grp-label grp-label-${g} grp-${g}`} colspan=${span} scope="colgroup">${GROUP_LABELS[g]}</th>`);
}
return html`<tr class="grp-label-row">${cells}</tr>`;
};
// Continuous temperature colour scale - hoisted so both the table columns
// and the thermal stress legend can share the same function.
const TEMP_STOPS = [
[-10, [ 90, 155, 220]],
[ 0, [140, 195, 235]],
[ 10, [155, 215, 195]],
[ 16, [140, 210, 140]],
[ 20, [195, 225, 110]],
[ 24, [240, 225, 80]],
[ 28, [250, 175, 65]],
[ 32, [240, 120, 55]],
[ 36, [220, 70, 50]],
[ 40, [185, 30, 30]],
[ 50, [130, 0, 20]],
];
const airTempRgb = (t, whiteMix = 0.58) => {
if (t == null) return null;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
let i = TEMP_STOPS.findIndex(s => t < s[0]);
if (i === -1) i = TEMP_STOPS.length;
const raw = i === 0 ? TEMP_STOPS[0][1]
: i >= TEMP_STOPS.length ? TEMP_STOPS[TEMP_STOPS.length-1][1]
: (() => {
const [t0, c0] = TEMP_STOPS[i-1];
const [t1, c1] = TEMP_STOPS[i];
const x = clamp((t - t0) / (t1 - t0), 0, 1);
const lerp = (a, b) => Math.round(a + (b - a) * x);
return [lerp(c0[0],c1[0]), lerp(c0[1],c1[1]), lerp(c0[2],c1[2])];
})();
return raw.map(c => Math.min(255, Math.round(c + (255 - c) * whiteMix)));
};
// Pet columns (Fur Colour, Pet Shade, Pet Home, Paw) reuse airTempRgb
// exactly as-is - identical stops, identical whiteMix blend, identical
// per-row top/bottom cell blending (petAirTempBg mirrors airTempBg
// below). The only thing that differs is which temperature gets handed
// to it: petEquivHumanTemp() remaps a pet reading to "the human felt-temp
// this severity is equivalent to" first, using the exact same anchor
// pairs PET_BANDS was calibrated against (same tier, same ordinal
// position in UTCI_BANDS vs PET_BANDS - see utils.js). So a -2 -C pet
// reading (mild "Cold", not "Freezing") gets looked up as if it were a
// few degrees warmer on the human scale, and a 46 -C paw reading (mid
// "Extreme", not "Danger") looks up around human "Extreme" too - never a
// different colour-computation, just a different input to the same one.
const PET_TO_HUMAN_TEMP = [
[-28, -20], [-18, -10], [-8, 0], [-3, 5], [2, 10], [7, 15], [11, 19],
[25, 24], [32, 27], [40, 32], [52, 41],
];
const petEquivHumanTemp = (t) => {
if (t == null) return null;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const pts = PET_TO_HUMAN_TEMP;
const slopeBetween = (a, b) => (b[1] - a[1]) / (b[0] - a[0]);
if (t <= pts[0][0]) {
const slope = slopeBetween(pts[0], pts[1]);
return pts[0][1] + (t - pts[0][0]) * slope;
}
if (t >= pts[pts.length - 1][0]) {
const last = pts[pts.length - 1], prev = pts[pts.length - 2];
const slope = slopeBetween(prev, last);
return last[1] + (t - last[0]) * slope;
}
for (let i = 1; i < pts.length; i++) {
const [p0, h0] = pts[i - 1], [p1, h1] = pts[i];
if (t <= p1) {
const x = clamp((t - p0) / (p1 - p0), 0, 1);
return h0 + (h1 - h0) * x;
}
}
};
const petAirTempRgb = (t, whiteMix = 0.58) => airTempRgb(petEquivHumanTemp(t), whiteMix);
// Continuous temperature colour scale — airTempRgb / petAirTempRgb now
// live in tableColumns.js alongside the cell renderers that use them, and
// are imported at the top of this file for the thermal-stress legend.
// ─── 3b. PANEL COMPUTATIONS ──────────────────────────────────────────
// whyFeelsLike is derived below, after the playback rows are resolved, so
// the panel can track the simulated instant during play/scrub (see panelRow).
@@ -571,6 +506,18 @@ export function UTCIForecast() {
? `Air quality and pollen are only forecast to ${new Date(`${aqHorizon}T00:00:00Z`)
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}`
: null;
// ─── TABLE COLUMN REGISTRY ───────────────────────────────────────────
// One definition per metric (label, unit, group, colouring, formatting),
// shared by both table orientations — see tableColumns.js. The array
// order is the column order normally, and the row order when rotated.
const columnDefs = buildColumnDefs({
visibleCols, indoorMode, indoorManaged,
showDecimals, showUnits,
skinType, utciEnv, pollenType,
aqBeyond, aqBeyondNote,
});
const visibleColumnDefs = columnDefs.filter(d => d.visible);
const glanceSummary = computeGlanceSummary(
days[selectedDay]?.rows ?? [],
activeProfile,
@@ -1424,6 +1371,129 @@ export function UTCIForecast() {
</div>
</div>
${(() => {
// ── THE HOURLY TABLE, IN EITHER ORIENTATION ──────────────
// Normally hours run down the page and metrics across it.
// Rotated, the axes swap: hours along the top, metrics down
// the side. Both read the same visibleColumnDefs registry
// (see tableColumns.js), so the cells are identical either
// way — only the axis they are laid out on changes.
// Is this hour "now", and is the sun below the horizon?
const hourFlags = (r) => ({
isNight: r.elev < 0,
isNow: r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO,
});
// The scope + "3pm" + event tags cell. Leads each row
// normally; heads each column when rotated.
const timeCell = (r) => {
// 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`;
const rowEvents = getCellTagEvents(selectedDayEvents, r);
return html`
<span class="utci-time-inner">
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} />
<span>${localHHMM}</span>
${rowEvents.map((ev, idx) => html`
<span key=${ev.id} class="event-cell-tag"
onClick=${(e) => handleEventTagClick(rowEvents, idx, e)}
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)}
onMouseLeave=${handleEventTagLeave}
role="button" tabIndex="0" aria-label=${ev.title}
>${ev.emoji}</span>
`)}
</span>`;
};
// Lives in the table's top-left cell in both orientations.
// stopPropagation so it doesn't also fire the header cell's
// column-info popup, same as the row-interval stepper.
const rotateBtn = html`
<button type="button"
class=${`table-rotate-btn${tableRotated ? ' on' : ''}`}
title=${tableRotated ? 'Hours down the side' : 'Hours along the top'}
aria-label="Rotate table"
aria-pressed=${tableRotated ? 'true' : 'false'}
onClick=${(e) => { e.stopPropagation(); toggleTableRotated(); }}
>⭮</button>`;
// ── ROTATED: hours across the top, metrics down the side ──
// One table, not two: the sticky top row and sticky left
// column are pure CSS here, so none of the head/body width
// syncing in useTableScroll is needed (it is disabled while
// rotated).
if (tableRotated) return html`
<div class=${`utci-table-wrap is-rotated${showUnits ? '' : ' no-units'}`}>
<div class="utci-rotated-scroll">
<table class="utci-table utci-table-rotated">
<thead>
<tr>
<th class="rot-corner col-info-th" scope="col"
onClick=${(e) => handleThClick('hour', e)}
onMouseEnter=${(e) => handleThEnter('hour', e)}
onMouseLeave=${handleThLeave}>
<span class="rot-corner-date">${glanceDate}</span>
<span class="rot-corner-controls">
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
</span>
${rotateBtn}
</span>
</th>
${tableRows.map(r => {
const f = hourFlags(r);
return html`
<th key=${r.iso} scope="col"
class=${`rot-hour-th ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}>
${timeCell(r)}
</th>`;
})}
</tr>
</thead>
<tbody>
${visibleColumnDefs.map((d, i) => {
// A group heading row is emitted whenever the
// group changes, standing in for the spanning
// group labels above the columns normally.
const newGroup = d.group && d.group !== (i > 0 ? visibleColumnDefs[i - 1].group : null) ? d.group : null;
return html`
<${Fragment} key=${d.key}>
${newGroup && html`
<tr class="grp-label-row grp-label-row--rotated">
<th class=${`grp-label grp-label-${newGroup} grp-${newGroup}`} scope="rowgroup" colspan=${tableRows.length + 1}>
${GROUP_LABELS[newGroup]}
</th>
</tr>`}
<tr class="rot-metric-row">
<th scope="row"
class=${`rot-metric-label col-info-th ${groupColor(d.key)}`.trim()}
onClick=${(e) => handleThClick(d.key, e)}
onMouseEnter=${(e) => handleThEnter(d.key, e)}
onMouseLeave=${handleThLeave}>
${d.label} <span class=${d.unitNone ? 'col-unit col-unit--none' : 'col-unit'}>${d.unit}</span>${d.headExtra ?? null}
</th>
${tableRows.map((r, hi) => {
const c = d.render(r, tableRows[hi - 1], tableRows[hi + 1], 'to right');
const f = hourFlags(r);
return html`<td key=${r.iso}
class=${`${d.cellClass ?? ''} ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}
title=${c.title ?? null}
style=${c.style ?? null}>${c.content}</td>`;
})}
</tr>
</${Fragment}>`;
})}
</tbody>
</table>
</div>
</div>`;
// ── NORMAL: hours down the side, metrics across the top ───
// Split into a sticky header table and a scrolling body
// table whose column widths useTableScroll keeps in step.
return html`
<div class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}${showUnits ? '' : ' no-units'}`} ref=${tableWrapRef}>
<span class="utci-scroll-chevron left" aria-hidden="true"></span>
<span class="utci-scroll-chevron right" aria-hidden="true"></span>
@@ -1434,46 +1504,21 @@ export function UTCIForecast() {
<thead>
${groupLabelRow()}
<tr>
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>
<span>Rows</span>
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
</span>
</th>
${visibleCols.utciP && html`<th class=${`col-info-th ${groupStart('utciP')} ${groupColor('utciP')}`} scope="col" onClick=${(e) => handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>SunSoak <span class="col-unit">°C felt</span>${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ? html`<span class="col-env-badge">${UTCI_ENVIRONMENTS[utciEnv].shortLabel}</span>` : null}</th>`}
${visibleCols.shadeT && html`<th class=${`utci-tight-head col-info-th ${groupStart('shadeT')} ${groupColor('shadeT')}`} scope="col" onClick=${(e) => handleThClick('shadeT', e)} onMouseEnter=${(e) => handleThEnter('shadeT', e)} onMouseLeave=${handleThLeave}>Shade <span class="col-unit">°C felt</span></th>`}
${visibleCols.vehicleT && html`<th class=${`col-info-th ${groupStart('vehicleT')} ${groupColor('vehicleT')}`} scope="col" onClick=${(e) => handleThClick('vehicleT', e)} onMouseEnter=${(e) => handleThEnter('vehicleT', e)} onMouseLeave=${handleThLeave}>Vehicle <span class="col-unit">°C peak</span></th>`}
${indoorMode === 'on' && !indoorManaged && html`<th class=${`col-info-th ${groupStart('indoorT')} ${groupColor('indoorT')}`} scope="col" onClick=${(e) => handleThClick('indoorT', e)} onMouseEnter=${(e) => handleThEnter('indoorT', e)} onMouseLeave=${handleThLeave}>Indoors <span class="col-unit">°C est.</span></th>`}
${indoorMode === 'on' && indoorManaged && html`<th class=${`col-info-th ${groupStart('managedT')} ${groupColor('managedT')}`} scope="col" onClick=${(e) => handleThClick('managedT', e)} onMouseEnter=${(e) => handleThEnter('managedT', e)} onMouseLeave=${handleThLeave}>Managed <span class="col-unit">°C est.</span></th>`}
${visibleCols.burn && html`<th class=${`col-info-th ${groupStart('burn')} ${groupColor('burn')}`} scope="col" onClick=${(e) => handleThClick('burn', e)} onMouseEnter=${(e) => handleThEnter('burn', e)} onMouseLeave=${handleThLeave}>Burn <span class="col-unit">to MED</span></th>`}
${visibleCols.utci && html`<th class=${`col-info-th ${groupStart('utci')} ${groupColor('utci')}`} scope="col" onClick=${(e) => handleThClick('utci', e)} onMouseEnter=${(e) => handleThEnter('utci', e)} onMouseLeave=${handleThLeave}>UTCI <span class="col-unit">°C felt</span></th>`}
${visibleCols.delta && html`<th class=${`col-info-th ${groupStart('delta')} ${groupColor('delta')}`} scope="col" onClick=${(e) => handleThClick('delta', e)} onMouseEnter=${(e) => handleThEnter('delta', e)} onMouseLeave=${handleThLeave}>Δ <span class="col-unit">UTCIAir</span></th>`}
${visibleCols.tmrt && html`<th class=${`col-info-th ${groupStart('tmrt')} ${groupColor('tmrt')}`} scope="col" onClick=${(e) => handleThClick('tmrt', e)} onMouseEnter=${(e) => handleThEnter('tmrt', e)} onMouseLeave=${handleThLeave}>Tmrt <span class="col-unit">°C</span></th>`}
${visibleCols.concreteT && html`<th class=${`col-info-th ${groupStart('concreteT')} ${groupColor('concreteT')}`} scope="col" onClick=${(e) => handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete <span class="col-unit">°C surface</span></th>`}
${visibleCols.furSurfaceT && html`<th class=${`col-info-th ${groupStart('furSurfaceT')} ${groupColor('furSurfaceT')}`} scope="col" onClick=${(e) => handleThClick('furSurfaceT', e)} onMouseEnter=${(e) => handleThEnter('furSurfaceT', e)} onMouseLeave=${handleThLeave}>Fur <span class="col-unit">°C surface</span></th>`}
${visibleCols.pawT && html`<th class=${`col-info-th ${groupStart('pawT')} ${groupColor('pawT')}`} scope="col" onClick=${(e) => handleThClick('pawT', e)} onMouseEnter=${(e) => handleThEnter('pawT', e)} onMouseLeave=${handleThLeave}>Paw <span class="col-unit">°C surface</span></th>`}
${visibleCols.soilT && html`<th class=${`col-info-th ${groupStart('soilT')} ${groupColor('soilT')}`} scope="col" onClick=${(e) => handleThClick('soilT', e)} onMouseEnter=${(e) => handleThEnter('soilT', e)} onMouseLeave=${handleThLeave}>Soil °C <span class="col-unit">surface</span></th>`}
${visibleCols.soilT6 && html`<th class=${`col-info-th ${groupStart('soilT6')} ${groupColor('soilT6')}`} scope="col" onClick=${(e) => handleThClick('soilT6', e)} onMouseEnter=${(e) => handleThEnter('soilT6', e)} onMouseLeave=${handleThLeave}>Soil 6cm <span class="col-unit">°C root</span></th>`}
${visibleCols.soilM && html`<th class=${`col-info-th ${groupStart('soilM')} ${groupColor('soilM')}`} scope="col" onClick=${(e) => handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist <span class="col-unit">%</span></th>`}
${visibleCols.petShadeT && html`<th class=${`utci-tight-head col-info-th ${groupStart('petShadeT')} ${groupColor('petShadeT')}`} scope="col" onClick=${(e) => handleThClick('petShadeT', e)} onMouseEnter=${(e) => handleThEnter('petShadeT', e)} onMouseLeave=${handleThLeave}>Pet Shade <span class="col-unit">°C</span></th>`}
${visibleCols.petHomeT && html`<th class=${`col-info-th ${groupStart('petHomeT')} ${groupColor('petHomeT')}`} scope="col" onClick=${(e) => handleThClick('petHomeT', e)} onMouseEnter=${(e) => handleThEnter('petHomeT', e)} onMouseLeave=${handleThLeave}>Pet Home <span class="col-unit">°C est.</span></th>`}
${visibleCols.air && html`<th class=${`utci-tight-head col-info-th ${groupStart('air')} ${groupColor('air')}`} scope="col" onClick=${(e) => handleThClick('air', e)} onMouseEnter=${(e) => handleThEnter('air', e)} onMouseLeave=${handleThLeave}>Air <span class="col-unit">°C</span></th>`}
${visibleCols.rh && html`<th class=${`col-info-th ${groupStart('rh')} ${groupColor('rh')}`} scope="col" onClick=${(e) => handleThClick('rh', e)} onMouseEnter=${(e) => handleThEnter('rh', e)} onMouseLeave=${handleThLeave}>RH <span class="col-unit">%</span></th>`}
${visibleCols.dew && html`<th class=${`col-info-th ${groupStart('dew')} ${groupColor('dew')}`} scope="col" onClick=${(e) => handleThClick('dew', e)} onMouseEnter=${(e) => handleThEnter('dew', e)} onMouseLeave=${handleThLeave}>Dew <span class="col-unit">°C</span></th>`}
${visibleCols.precip && html`<th class=${`utci-tight-head col-info-th ${groupStart('precip')} ${groupColor('precip')}`} scope="col" onClick=${(e) => handleThClick('precip', e)} onMouseEnter=${(e) => handleThEnter('precip', e)} onMouseLeave=${handleThLeave}>Pcpt <span class="col-unit">mm/h</span></th>`}
${visibleCols.precipProb && html`<th class=${`utci-tight-head col-info-th ${groupStart('precipProb')} ${groupColor('precipProb')}`} scope="col" onClick=${(e) => handleThClick('precipProb', e)} onMouseEnter=${(e) => handleThEnter('precipProb', e)} onMouseLeave=${handleThLeave}>Rain <span class="col-unit">%</span></th>`}
${visibleCols.lightning && html`<th class=${`utci-tight-head col-info-th ${groupStart('lightning')} ${groupColor('lightning')}`} scope="col" onClick=${(e) => handleThClick('lightning', e)} onMouseEnter=${(e) => handleThEnter('lightning', e)} onMouseLeave=${handleThLeave}>⚡ <span class="col-unit">LPI</span></th>`}
${visibleCols.cloud && html`<th class=${`col-info-th ${groupStart('cloud')} ${groupColor('cloud')}`} scope="col" onClick=${(e) => handleThClick('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud <span class="col-unit">%</span></th>`}
${visibleCols.vis && html`<th class=${`utci-tight-head col-info-th ${groupStart('vis')} ${groupColor('vis')}`} scope="col" onClick=${(e) => handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Vis <span class="col-unit">km</span></th>`}
${visibleCols.wind && html`<th class=${`col-info-th ${groupStart('wind')} ${groupColor('wind')}`} scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">mph (gust)</span></th>`}
${visibleCols.dir && html`<th class=${`utci-dir-cell col-info-th ${groupStart('dir')} ${groupColor('dir')}`} scope="col" onClick=${(e) => handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir <span class="col-unit">-</span></th>`}
${visibleCols.aqi && html`<th class=${`col-info-th ${groupStart('aqi')} ${groupColor('aqi')}`} scope="col" onClick=${(e) => handleThClick('aqi', e)} onMouseEnter=${(e) => handleThEnter('aqi', e)} onMouseLeave=${handleThLeave}>AQI <span class=${aqBeyond ? 'col-unit col-unit--none' : 'col-unit'}>${aqBeyond ? 'no data' : 'EU idx'}</span></th>`}
${visibleCols.pollen && html`<th class=${`col-info-th ${groupStart('pollen')} ${groupColor('pollen')}`} scope="col" onClick=${(e) => handleThClick('pollen', e)} onMouseEnter=${(e) => handleThEnter('pollen', e)} onMouseLeave=${handleThLeave}>${pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen','') ?? 'Pollen'} <span class=${aqBeyond ? 'col-unit col-unit--none' : 'col-unit'}>${aqBeyond ? 'no data' : 'grains/m³'}</span></th>`}
${visibleCols.uvA && html`<th class=${`col-info-th ${groupStart('uvA')} ${groupColor('uvA')}`} scope="col" onClick=${(e) => handleThClick('uvA', e)} onMouseEnter=${(e) => handleThEnter('uvA', e)} onMouseLeave=${handleThLeave}>UV-A <span class="col-unit">est. idx</span></th>`}
${visibleCols.uvB && html`<th class=${`col-info-th ${groupStart('uvB')} ${groupColor('uvB')}`} scope="col" onClick=${(e) => handleThClick('uvB', e)} onMouseEnter=${(e) => handleThEnter('uvB', e)} onMouseLeave=${handleThLeave}>UV-B <span class="col-unit">est. idx</span></th>`}
${visibleCols.sun && html`<th class=${`col-info-th ${groupStart('sun')} ${groupColor('sun')}`} scope="col" onClick=${(e) => handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun <span class="col-unit">elev°</span></th>`}
${visibleCols.direct && html`<th class=${`col-info-th ${groupStart('direct')} ${groupColor('direct')}`} scope="col" onClick=${(e) => handleThClick('direct', e)} onMouseEnter=${(e) => handleThEnter('direct', e)} onMouseLeave=${handleThLeave}>Direct <span class="col-unit">W/m²</span></th>`}
${visibleCols.diffuse && html`<th class=${`col-info-th ${groupStart('diffuse')} ${groupColor('diffuse')}`} scope="col" onClick=${(e) => handleThClick('diffuse', e)} onMouseEnter=${(e) => handleThEnter('diffuse', e)} onMouseLeave=${handleThLeave}>Diffuse <span class="col-unit">W/m²</span></th>`}
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>
<span>Rows</span>
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
</span>
${rotateBtn}
</th>
${visibleColumnDefs.map(d => html`
<th key=${d.key} scope="col"
class=${`col-info-th ${d.headClass ?? ''} ${groupStart(d.key)} ${groupColor(d.key)}`.replace(/\s+/g, ' ').trim()}
onClick=${(e) => handleThClick(d.key, e)}
onMouseEnter=${(e) => handleThEnter(d.key, e)}
onMouseLeave=${handleThLeave}>
${d.label} <span class=${d.unitNone ? 'col-unit col-unit--none' : 'col-unit'}>${d.unit}</span>${d.headExtra ?? null}
</th>`)}
</tr>
</thead>
</table>
@@ -1484,310 +1529,25 @@ export function UTCIForecast() {
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
<tbody>
${tableRows.map((r, rowIdx) => {
const rPrev = tableRows[rowIdx - 1];
const rNext = tableRows[rowIdx + 1];
const cat = utciCategory(r.utci);
const isNight = r.elev < 0;
const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO;
const delta = r.utci - r.Ta;
const adjCat = utciCategory(r.utciAdj);
// Converts a band hex colour to rgba at the given alpha — used to
// tint the UTCI and UTCI+P cells directly from the band colour.
const hexToRgba = (hex, a) => {
const h = hex.replace('#', '');
const r = parseInt(h.slice(0,2),16);
const g = parseInt(h.slice(2,4),16);
const b = parseInt(h.slice(4,6),16);
return `rgba(${r},${g},${b},${a})`;
};
const fmt = (v, dp = 1) => v == null ? '—' : (showDecimals ? v.toFixed(dp) : String(Math.round(v)));
const u = (unit) => showUnits ? unit : '';
// Returns a background tint based on temperature value - all share the same
// Continuous temperature colour scale — used for all temp columns.
// airTempRgb(t) returns the whiteblended [r,g,b] for a temperature.
// airTempBg(t, tPrev, tNext) returns a top→bottom gradient that
// flows from the previous row's colour through this row's colour to
// the next row's colour, making the whole column one seamless heatmap.
// airTempRgb and TEMP_STOPS hoisted to component scope above.
const toRgb = (rgb) => rgb ? `rgb(${rgb[0]},${rgb[1]},${rgb[2]})` : 'transparent';
const airTempBg = (t, tPrev, tNext) => {
if (t == null) return 'transparent';
// Top colour = midpoint temp between prev row and this row.
// Bottom colour = midpoint temp between this row and next row.
// This guarantees the boundary colour is identical on both sides
// of every cell edge, giving a perfectly seamless column gradient.
const tTop = tPrev != null ? (tPrev + t) / 2 : t;
const tBot = tNext != null ? (t + tNext) / 2 : t;
return `linear-gradient(to bottom, ${toRgb(airTempRgb(tTop))} 0%, ${toRgb(airTempRgb(tBot))} 100%)`;
};
const airTempFontColor = () => '#1a1a1a';
// Returns any extra band styles (fontWeight, textShadow) for a given temp.
const bandExtras = (t) => {
const band = utciCategory(t);
if (!band) return {};
return {
...(band.fontWeight ? { fontWeight: band.fontWeight } : {}),
...(band.textShadow ? { textShadow: band.textShadow } : {}),
};
};
// Pet-specific columns (Fur Colour, Pet Shade, Pet Home, Paw) use
// petAirTempRgb instead of airTempRgb - identical gradient/blend
// mechanics to every other temp column, just a pet-calibrated
// scale so the same degree reading lands on a different shade.
const petAirTempBg = (t, tPrev, tNext) => {
if (t == null) return 'transparent';
const tTop = tPrev != null ? (tPrev + t) / 2 : t;
const tBot = tNext != null ? (t + tNext) / 2 : t;
return `linear-gradient(to bottom, ${toRgb(petAirTempRgb(tTop))} 0%, ${toRgb(petAirTempRgb(tBot))} 100%)`;
};
const scaleBg = (v, min, max, rgb, maxAlpha = 0.14) => {
if (v == null || isNaN(v)) return 'transparent';
if (v <= min) return 'transparent';
const x = Math.max(0, Math.min(1, (v - min) / (max - min)));
const a = 0.035 + x * maxAlpha;
return `rgba(${rgb},${a.toFixed(3)})`;
};
const soilMoistureBg = (v) => {
if (v == null || isNaN(v)) return { bg: 'transparent', fg: '#555' };
const pct = v * 100;
// stops: [pct, r, g, b]
const stops = [
[0, 212, 180, 131], // sandy tan bone dry
[10, 160, 120, 64], // mid brown dry
[22, 138, 122, 80], // olive-brown damp/firm
[32, 200, 160, 64], // amber soft, caution
[38, 58, 138, 92], // green wet
[50, 42, 106, 144], // blue saturated
];
let s0 = stops[0], s1 = stops[stops.length - 1];
for (let i = 0; i < stops.length - 1; i++) {
if (pct >= stops[i][0] && pct <= stops[i+1][0]) { s0 = stops[i]; s1 = stops[i+1]; break; }
}
const t = s1[0] === s0[0] ? 1 : Math.max(0, Math.min(1, (pct - s0[0]) / (s1[0] - s0[0])));
const r = Math.round(s0[1] + t * (s1[1] - s0[1]));
const g = Math.round(s0[2] + t * (s1[2] - s0[2]));
const b = Math.round(s0[3] + t * (s1[3] - s0[3]));
const lum = 0.299*r + 0.587*g + 0.114*b;
return { bg: `rgba(${r},${g},${b},0.18)`, fg: '#3a2a10' };
};
const deltaBg = (v) => {
if (v == null || isNaN(v)) return 'transparent';
if (Math.abs(v) < 1) return 'transparent';
const x = Math.min(1, Math.abs(v) / 12);
const a = 0.035 + x * 0.13;
return v > 0 ? `rgba(230,140,50,${a.toFixed(3)})` : `rgba(80,135,210,${a.toFixed(3)})`;
};
const burnBg = (mins, uv) => {
if (!isFinite(mins) || uv <= 0) return 'transparent';
if (mins >= 240) return 'transparent';
const x = Math.max(0, Math.min(1, (240 - mins) / 220));
return `rgba(210,70,50,${(0.04 + x * 0.15).toFixed(3)})`;
};
// 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`;
const burnMins = sunburnMinutes(r.uv, skinType);
const f = hourFlags(r);
return html`
<tr key=${r.iso}
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
<td class="utci-time">
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} />
<span>${localHHMM}</span>
${(() => {
const rowEvents = getCellTagEvents(selectedDayEvents, r);
return rowEvents.map((ev, idx) => html`
<span key=${ev.id} class="event-cell-tag"
onClick=${(e) => handleEventTagClick(rowEvents, idx, e)}
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)}
onMouseLeave=${handleEventTagLeave}
role="button" tabIndex="0" aria-label=${ev.title}
>${ev.emoji}</span>
`);
})()}
</span>
</td>
${visibleCols.utciP && html`
<td class=${groupStart('utciP')} style=${{ background: airTempBg(r.utciAdj, rPrev?.utciAdj, rNext?.utciAdj), color: airTempFontColor(), fontSize: '13px' }}>
${fmt(r.utciAdj)}${u('°C')}
</td>`}
${visibleCols.shadeT && html`
<td class=${groupStart('shadeT')} style=${{ color: airTempFontColor(), background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT) }}>
${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}
</td>`}
${visibleCols.vehicleT && html`
<td class=${groupStart('vehicleT')} style=${{ color: airTempFontColor(), background: airTempBg(r.vehicleT, rPrev?.vehicleT, rNext?.vehicleT) }}>
${fmt(r.vehicleT)}${u('°C')}
</td>`}
${indoorMode === 'on' && !indoorManaged && html`
<td class=${groupStart('indoorT')} style=${{ color: airTempFontColor(), background: airTempBg(r.indoorT, rPrev?.indoorT, rNext?.indoorT) }}>
${fmt(r.indoorT)}${u('°C')}
</td>`}
${indoorMode === 'on' && indoorManaged && html`
<td class=${groupStart('managedT')} style=${{ color: airTempFontColor(), background: airTempBg(r.managedT, rPrev?.managedT, rNext?.managedT) }}>
${fmt(r.managedT)}${u('°C')}
</td>`}
${visibleCols.burn && html`
<td class=${groupStart('burn')} style=${{ color: r.uv > 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins, r.uv) }}>
${burnLabel(burnMins)}
</td>`}
${visibleCols.utci && html`
<td class=${groupStart('utci')} style=${{ background: airTempBg(r.utci, rPrev?.utci, rNext?.utci), color: airTempFontColor() }}>
${fmt(r.utci)}${u('°C')}
</td>`}
${visibleCols.delta && html`
<td class=${groupStart('delta')} style=${{
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218',
background: deltaBg(delta),
}}>
${delta > 0 ? '+' : ''}${fmt(delta)}${u('°C')}
</td>`}
${visibleCols.tmrt && html`<td class=${groupStart('tmrt')} style=${{ background: airTempBg(r.Tmrt, rPrev?.Tmrt, rNext?.Tmrt), color: airTempFontColor() }}>${fmt(r.Tmrt)}${u('°C')}</td>`}
${visibleCols.concreteT && html`
<td class=${groupStart('concreteT')} style=${{ color: airTempFontColor(), background: airTempBg(r.concreteT, rPrev?.concreteT, rNext?.concreteT) }}>
${fmt(r.concreteT)}${u('°C')}
</td>`}
${visibleCols.furSurfaceT && html`
<td class=${groupStart('furSurfaceT')} title=${petCategory(r.furSurfaceT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.furSurfaceT, rPrev?.furSurfaceT, rNext?.furSurfaceT) }}>
${fmt(r.furSurfaceT)}${u('°C')}
</td>`}
${visibleCols.pawT && html`
<td class=${groupStart('pawT')} title=${petCategory(r.pawT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.pawT, rPrev?.pawT, rNext?.pawT) }}>
${fmt(r.pawT)}${u('°C')}
</td>`}
${visibleCols.soilT && html`
<td class=${groupStart('soilT')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT0, rPrev?.soilT0, rNext?.soilT0) }}>${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}</td>`}
${visibleCols.soilT6 && html`
<td class=${groupStart('soilT6')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT6, rPrev?.soilT6, rNext?.soilT6) }}>${r.soilT6 != null ? fmt(r.soilT6) + u('°C') : '—'}</td>`}
${visibleCols.soilM && html`
<td class=${groupStart('soilM')} style=${(() => { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}</td>`}
${visibleCols.petShadeT && html`
<td class=${groupStart('petShadeT')} title=${r.petShadeT != null ? (petCategory(r.petShadeT)?.label ?? '') : ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.petShadeT, rPrev?.petShadeT, rNext?.petShadeT) }}>
${r.petShadeT != null ? fmt(r.petShadeT) + u('°C') : '—'}
</td>`}
${visibleCols.petHomeT && html`
<td class=${groupStart('petHomeT')} title=${petCategory(r.indoorT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.indoorT, rPrev?.indoorT, rNext?.indoorT) }}>
${fmt(r.indoorT)}${u('°C')}
</td>`}
${visibleCols.air && html`<td class=${groupStart('air')} style=${{ background: airTempBg(r.Ta, rPrev?.Ta, rNext?.Ta), color: airTempFontColor() }}>${fmt(r.Ta)}${u('°C')}</td>`}
${visibleCols.rh && html`<td class=${groupStart('rh')} style=${{ background: scaleBg(r.RH, 30, 100, '70,145,200') }}>${Math.round(r.RH)}${u('%')}</td>`}
${visibleCols.dew && html`<td class=${groupStart('dew')} style=${{ background: airTempBg(r.dew, rPrev?.dew, rNext?.dew), color: airTempFontColor() }}>${fmt(r.dew)}${u('°C')}</td>`}
${visibleCols.precip && html`
<td class=${groupStart('precip')} style=${{ background: r.snow > 0 ? scaleBg(r.snow, 0, 4, '90,140,210') : scaleBg(r.precip, 0, 8, '70,145,200') }}>
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '5px', verticalAlign: 'middle' }}>
<${PrecipIcon} precip=${r.precipProb > 0 ? r.precip : 0} snow=${r.precipProb > 0 ? r.snow : 0} size=${28} />
<span style=${{ color: r.snow > 0 ? '#2a5fa8' : r.precip > 0 ? '#2a6a90' : '#7a5c30' }}>
${r.precipProb > 0
? (r.snow > 0 ? fmt(r.snow) + u('cm') : r.precip > 0 ? fmt(r.precip) + u('mm') : '—')
: '—'}
</span>
</span>
</td>`}
${visibleCols.precipProb && html`
<td class=${groupStart('precipProb')} style=${{ background: scaleBg(r.precipProb, 0, 100, '70,145,200'), color: r.precipProb >= 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90' }}>
${r.precipProb}${u('%')}
</td>`}
${visibleCols.lightning && (() => {
const lpi = r.lightning ?? 0;
const bg = lpi >= 25 ? 'rgba(255,180,0,0.25)'
: lpi >= 5 ? 'rgba(255,210,0,0.15)'
: 'transparent';
const color = lpi >= 25 ? '#7a4800' : lpi >= 5 ? '#6a5800' : '#7a8a90';
return html`<td class=${groupStart('lightning')} style=${{ background: bg, color, textAlign: 'center' }}>
${lpi > 0 ? `${Math.round(lpi)}` : ''}
</td>`;
})()}
${visibleCols.cloud && html`<td class=${groupStart('cloud')} style=${{ background: scaleBg(r.cc, 0, 100, '110,130,150', 0.07), verticalAlign: 'middle' }}>
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
<span style=${{ position: 'relative', top: '6px' }}>
<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} />
</span>
<span>${Math.round(r.cc)}${u('%')}</span>
</span>
</td>`}
${visibleCols.vis && (() => {
const v = r.visKm;
const bg = v == null ? 'transparent'
: v < 1 ? 'rgba(180,80,80,0.18)'
: v < 4 ? 'rgba(210,140,50,0.15)'
: v < 10 ? 'rgba(200,190,80,0.12)'
: 'transparent';
const color = v != null && v < 4 ? '#8a3a1a' : '#4a3218';
return html`<td class=${groupStart('vis')} style=${{ background: bg, color }}>
${v != null ? fmt(v) + u('km') : '—'}
</td>`;
})()}
${visibleCols.wind && html`<td class=${groupStart('wind')} style=${{ background: scaleBg((r.gust ?? r.va) * 2.237, 0, 40, '85,130,180') }}>
${Math.round(r.va * 2.237)}${u('mph')}${r.gust != null && r.gust > r.va + 0.5
? (gm => html`<span style=${{ marginLeft: '4px', fontWeight: gm >= 25 ? 700 : 'normal', opacity: gm >= 25 ? 1 : 0.65, color: gm >= 55 ? '#b81010' : gm >= 40 ? '#d44010' : gm >= 25 ? '#c47a00' : 'inherit' }}>(${Math.round(gm)}${u('mph')})</span>`)(r.gust * 2.237)
: ''}
</td>`}
${visibleCols.dir && html`<td class=${`utci-dir-cell ${groupStart('dir')}`}>
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
<${WindVane} bearing=${r.wd} size=${28} />
<span class="wind-dir-label" style=${{ fontFamily: 'Manrope, sans-serif', fontSize: '11px', fontWeight: 700 }}>${r.compass.label}</span>
</span>
</td>`}
${visibleCols.aqi && (() => {
const v = r.aqi == null ? null : Math.round(r.aqi);
const bg = v == null ? 'transparent'
: v < 20 ? 'rgba(80,180,100,0.15)'
: v < 40 ? 'rgba(140,200,100,0.13)'
: v < 60 ? 'rgba(220,200,60,0.15)'
: v < 80 ? 'rgba(220,130,50,0.18)'
: v < 100 ? 'rgba(200,70,50,0.18)'
: 'rgba(160,30,100,0.20)';
const color = v != null && v >= 60 ? '#7a2010' : v != null && v >= 40 ? '#7a4a10' : '#2a4a20';
const label = v == null ? '—'
: v < 20 ? `${v} Good`
: v < 40 ? `${v} Fair`
: v < 60 ? `${v} Mod`
: v < 80 ? `${v} Poor`
: v < 100 ? `${v} V.Poor`
: `${v} Hazard`;
return html`<td class=${groupStart('aqi')} title=${v == null && aqBeyondNote ? aqBeyondNote : null} style=${{ background: bg, color, fontWeight: v != null && v >= 60 ? 600 : 400 }}>${label}</td>`;
})()}
${visibleCols.pollen && (() => {
const pollenMap = {
all_pollen: [r.grassPollen, r.birchPollen, r.alderPollen, r.mugwortPollen, r.olivePollen, r.ragweedPollen].reduce((s, x) => x != null ? s + x : s, null),
grass_pollen: r.grassPollen,
birch_pollen: r.birchPollen,
alder_pollen: r.alderPollen,
mugwort_pollen: r.mugwortPollen,
olive_pollen: r.olivePollen,
ragweed_pollen: r.ragweedPollen,
};
const v = pollenMap[pollenType] ?? null;
const bg = v == null ? 'transparent'
: v < 10 ? 'transparent'
: v < 50 ? 'rgba(180,200,80,0.13)'
: v < 200 ? 'rgba(210,150,50,0.16)'
: 'rgba(200,70,50,0.18)';
const color = v != null && v >= 200 ? '#8a2010' : v != null && v >= 50 ? '#7a4a10' : '#4a3218';
const label = v == null ? '—'
: v < 10 ? `${Math.round(v)} Low`
: v < 50 ? `${Math.round(v)} Mod`
: v < 200 ? `${Math.round(v)} High`
: `${Math.round(v)} V.High`;
return html`<td class=${groupStart('pollen')} title=${v == null && aqBeyondNote ? aqBeyondNote : null} style=${{ background: bg, color, fontWeight: v != null && v >= 50 ? 600 : 400 }}>${label}</td>`;
})()}
${visibleCols.uvA && html`
<td class=${groupStart('uvA')} style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}>
${r.uvA > 0 ? fmt(r.uvA) + u(' idx') : '—'}
</td>`}
${visibleCols.uvB && html`
<td class=${groupStart('uvB')} style=${{ color: r.uvB > 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}>
${r.uvB > 0 ? fmt(r.uvB, 2) + u(' idx') : '—'}
</td>`}
${visibleCols.sun && html`<td class=${groupStart('sun')} style=${{ background: scaleBg(r.elev > 0 ? r.elev : null, 0, 70, '225,160,45') }}>${r.elev > 0 ? fmt(r.elev) + u('°') : '—'}</td>`}
${visibleCols.direct && html`<td class=${groupStart('direct')} style=${{ background: scaleBg(r.dir, 0, 850, '230,155,35') }}>${Math.round(r.dir)}${u('W/m²')}</td>`}
${visibleCols.diffuse && html`<td class=${groupStart('diffuse')} style=${{ background: scaleBg(r.dif, 0, 450, '230,190,70') }}>${Math.round(r.dif)}${u('W/m²')}</td>`}
class=${`${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.trim()}>
<td class="utci-time">${timeCell(r)}</td>
${visibleColumnDefs.map(d => {
const c = d.render(r, tableRows[rowIdx - 1], tableRows[rowIdx + 1], 'to bottom');
return html`<td key=${d.key}
class=${`${d.cellClass ?? ''} ${groupStart(d.key)}`.trim()}
title=${c.title ?? null}
style=${c.style ?? null}>${c.content}</td>`;
})}
</tr>`;
})}
</tbody>
</table>
</div>
</div>
</div>`;
})()}
</div>
${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0 || visible.length > 0) && html`
<aside class="glance-rail">
+16
View File
@@ -637,6 +637,18 @@ export function useAppState() {
return next;
});
// Rotates the hourly table: off = hours down the side, on = hours along
// the top with the metrics down the side. Off by default — the tall
// layout is the one most people expect from a forecast table.
const [tableRotated, setTableRotated] = useState(() => {
try { return localStorage.getItem('sunscope_table_rotated') === '1'; } catch (e) { return false; }
});
const toggleTableRotated = () => setTableRotated(v => {
const next = !v;
try { localStorage.setItem('sunscope_table_rotated', next ? '1' : '0'); } catch (e) { /* ignore */ }
return next;
});
// Table row interval: 1 = every hour, 2/3/4 = clock-aligned buckets. Default 2.
const [tableInterval, setTableIntervalState] = useState(() => {
try { const n = parseInt(localStorage.getItem('sunscope_table_interval'), 10); return [1, 2, 3, 4].includes(n) ? n : 2; } catch (e) { return 2; }
@@ -741,6 +753,9 @@ export function useAppState() {
headTableRef, bodyTableRef, bodyScrollRef, headTrackRef, tableWrapRef,
forecast, visibleCols, selectedDay, skinType, vehicleType,
indoorMode, indoorManaged, showDecimals, showUnits,
// The rotated table is a single table with CSS-only sticky edges, so
// none of this hook's dual-table syncing applies to it.
rotated: tableRotated,
});
// ── 8. GEOCODING SEARCH ───────────────────────────────────────────────
@@ -888,6 +903,7 @@ export function useAppState() {
panelOpen, openPanel, closePanel,
showUnits, toggleShowUnits,
tableInterval, setTableInterval,
tableRotated, toggleTableRotated,
forecastView, setForecastView,
// table refs
headStickyRef, headTrackRef, headTableRef,
+16 -3
View File
@@ -39,6 +39,10 @@ export function useTableScroll({
indoorManaged,
showDecimals,
showUnits,
// When the table is rotated it renders as ONE table whose sticky header
// row and sticky metric column are handled entirely in CSS. There is no
// second table to keep in step, so every effect below short-circuits.
rotated = false,
}) {
// --- SCROLL INDICATORS ---------------------------------------------
const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false);
@@ -53,6 +57,7 @@ export function useTableScroll({
// --- DRAG-TO-SCROLL ------------------------------------------------
useEffect(() => {
if (rotated) return;
const el = bodyScrollRef.current;
if (!el) return;
let isDown = false;
@@ -99,12 +104,19 @@ export function useTableScroll({
document.removeEventListener('mouseup', onMouseUp);
el.removeEventListener('scroll', updateTableScrollIndicators);
};
}, [forecast]);
}, [forecast, rotated]);
// Update indicators after layout sync (columns may have changed width)
useEffect(() => {
if (rotated) {
// Nothing is scrolling under our control — clear the fades so they
// don't linger over the rotated table.
setTableCanScrollLeft(false);
setTableCanScrollRight(false);
return;
}
updateTableScrollIndicators();
}, [forecast, visibleCols, selectedDay]);
}, [forecast, visibleCols, selectedDay, rotated]);
// --- BODY SCROLL HANDLER (called from JSX onScroll) ---------------
const handleBodyScroll = () => {
@@ -129,6 +141,7 @@ export function useTableScroll({
// "shrink-to-fit then distribute" strategy. See the comments inside
// sync() for the algorithm.
useLayoutEffect(() => {
if (rotated) return;
const sync = () => {
const headTable = headTableRef.current;
const bodyTable = bodyTableRef.current;
@@ -233,7 +246,7 @@ export function useTableScroll({
if (ro) ro.disconnect();
window.removeEventListener('resize', sync);
};
}, [forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, showDecimals, showUnits]);
}, [forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, showDecimals, showUnits, rotated]);
return {
tableCanScrollLeft,
+493
View File
@@ -0,0 +1,493 @@
// ════════════════════════════════════════════════════════════════════════
// tableColumns.js — the single source of truth for the hourly table's
// metric columns.
//
// WHY THIS EXISTS
// ──────────────────────────────────────────────────────────────────────
// The table can be rendered in two orientations:
// • normal — rows = hours, columns = metrics
// • rotated — rows = metrics, columns = hours
// Both read from the same definitions here, so a metric is described
// once (label, unit, group, colouring, formatting) and both views stay
// in step. Adding a new metric means adding one entry to the array in
// buildColumnDefs().
//
// SHAPE OF A DEFINITION
// ──────────────────────────────────────────────────────────────────────
// key column id, matches visibleCols / COL_DESCRIPTIONS
// label heading text
// unit the small print under the heading ("°C", "mm/h", …)
// unitNone true → render the unit muted+italic (no data for this day)
// group felt | surface | ambient | precip | sky | wind | airqual | solar
// visible resolved from visibleCols / indoorMode
// headClass extra class on the heading cell ('utci-tight-head', …)
// cellClass extra class on the data cell ('utci-dir-cell', …)
// headExtra optional extra node inside the heading (the SunSoak badge)
// render(r, prev, next, dir) → { content, style, title }
// r/prev/next are the hour row and its neighbours; `dir` is the CSS
// gradient direction ('to bottom' normally, 'to right' rotated) so
// the temperature heatmaps flow along whichever axis time runs on.
// ════════════════════════════════════════════════════════════════════════
import { h } from '../vendor/preact.js';
import htm from '../vendor/htm.js';
import { petCategory, sunburnMinutes, burnLabel } from './utils.js';
import { WindVane, CloudIcon, PrecipIcon } from './components.js';
import { POLLEN_TYPES, UTCI_ENVIRONMENTS } from './config.js';
const html = htm.bind(h);
// ── COLOUR SCALE ────────────────────────────────────────────────────────
// Continuous temperature colour scale — shared by every temperature column
// and by the thermal-stress legend in app.js.
export const TEMP_STOPS = [
[-10, [ 90, 155, 220]],
[ 0, [140, 195, 235]],
[ 10, [155, 215, 195]],
[ 16, [140, 210, 140]],
[ 20, [195, 225, 110]],
[ 24, [240, 225, 80]],
[ 28, [250, 175, 65]],
[ 32, [240, 120, 55]],
[ 36, [220, 70, 50]],
[ 40, [185, 30, 30]],
[ 50, [130, 0, 20]],
];
export const airTempRgb = (t, whiteMix = 0.58) => {
if (t == null) return null;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
let i = TEMP_STOPS.findIndex(s => t < s[0]);
if (i === -1) i = TEMP_STOPS.length;
const raw = i === 0 ? TEMP_STOPS[0][1]
: i >= TEMP_STOPS.length ? TEMP_STOPS[TEMP_STOPS.length-1][1]
: (() => {
const [t0, c0] = TEMP_STOPS[i-1];
const [t1, c1] = TEMP_STOPS[i];
const x = clamp((t - t0) / (t1 - t0), 0, 1);
const lerp = (a, b) => Math.round(a + (b - a) * x);
return [lerp(c0[0],c1[0]), lerp(c0[1],c1[1]), lerp(c0[2],c1[2])];
})();
return raw.map(c => Math.min(255, Math.round(c + (255 - c) * whiteMix)));
};
// Pet columns (Fur Colour, Pet Shade, Pet Home, Paw) reuse airTempRgb
// exactly as-is - identical stops, identical whiteMix blend, identical
// neighbour blending. The only thing that differs is which temperature
// gets handed to it: petEquivHumanTemp() remaps a pet reading to "the
// human felt-temp this severity is equivalent to" first, using the exact
// same anchor pairs PET_BANDS was calibrated against (same tier, same
// ordinal position in UTCI_BANDS vs PET_BANDS - see utils.js). So a -2 °C
// pet reading (mild "Cold", not "Freezing") gets looked up as if it were
// a few degrees warmer on the human scale, and a 46 °C paw reading (mid
// "Extreme", not "Danger") looks up around human "Extreme" too - never a
// different colour-computation, just a different input to the same one.
const PET_TO_HUMAN_TEMP = [
[-28, -20], [-18, -10], [-8, 0], [-3, 5], [2, 10], [7, 15], [11, 19],
[25, 24], [32, 27], [40, 32], [52, 41],
];
const petEquivHumanTemp = (t) => {
if (t == null) return null;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const pts = PET_TO_HUMAN_TEMP;
const slopeBetween = (a, b) => (b[1] - a[1]) / (b[0] - a[0]);
if (t <= pts[0][0]) {
const slope = slopeBetween(pts[0], pts[1]);
return pts[0][1] + (t - pts[0][0]) * slope;
}
if (t >= pts[pts.length - 1][0]) {
const last = pts[pts.length - 1], prev = pts[pts.length - 2];
const slope = slopeBetween(prev, last);
return last[1] + (t - last[0]) * slope;
}
for (let i = 1; i < pts.length; i++) {
const [p0, h0] = pts[i - 1], [p1, h1] = pts[i];
if (t <= p1) {
const x = clamp((t - p0) / (p1 - p0), 0, 1);
return h0 + (h1 - h0) * x;
}
}
};
export const petAirTempRgb = (t, whiteMix = 0.58) => airTempRgb(petEquivHumanTemp(t), whiteMix);
// ── CELL BACKGROUND HELPERS ─────────────────────────────────────────────
const toRgb = (rgb) => rgb ? `rgb(${rgb[0]},${rgb[1]},${rgb[2]})` : 'transparent';
// Builds a gradient that flows from the previous hour's colour through
// this hour's colour to the next hour's. The end colours are the midpoint
// temperatures either side, so the boundary colour is identical on both
// sides of every cell edge — the whole line of cells reads as one seamless
// heatmap. `dir` picks the axis: 'to bottom' when hours run down the
// table, 'to right' when they run across it.
const makeTempBg = (rgbFn) => (t, tPrev, tNext, dir = 'to bottom') => {
if (t == null) return 'transparent';
const tStart = tPrev != null ? (tPrev + t) / 2 : t;
const tEnd = tNext != null ? (t + tNext) / 2 : t;
return `linear-gradient(${dir}, ${toRgb(rgbFn(tStart))} 0%, ${toRgb(rgbFn(tEnd))} 100%)`;
};
const airTempBg = makeTempBg(airTempRgb);
const petAirTempBg = makeTempBg(petAirTempRgb);
const airTempFontColor = () => '#1a1a1a';
// Linear alpha ramp between min and max — used by the non-temperature
// columns (humidity, wind, solar…) that tint rather than heatmap.
const scaleBg = (v, min, max, rgb, maxAlpha = 0.14) => {
if (v == null || isNaN(v)) return 'transparent';
if (v <= min) return 'transparent';
const x = Math.max(0, Math.min(1, (v - min) / (max - min)));
const a = 0.035 + x * maxAlpha;
return `rgba(${rgb},${a.toFixed(3)})`;
};
const soilMoistureBg = (v) => {
if (v == null || isNaN(v)) return { bg: 'transparent', fg: '#555' };
const pct = v * 100;
// stops: [pct, r, g, b]
const stops = [
[0, 212, 180, 131], // sandy tan bone dry
[10, 160, 120, 64], // mid brown dry
[22, 138, 122, 80], // olive-brown damp/firm
[32, 200, 160, 64], // amber soft, caution
[38, 58, 138, 92], // green wet
[50, 42, 106, 144], // blue saturated
];
let s0 = stops[0], s1 = stops[stops.length - 1];
for (let i = 0; i < stops.length - 1; i++) {
if (pct >= stops[i][0] && pct <= stops[i+1][0]) { s0 = stops[i]; s1 = stops[i+1]; break; }
}
const t = s1[0] === s0[0] ? 1 : Math.max(0, Math.min(1, (pct - s0[0]) / (s1[0] - s0[0])));
const r = Math.round(s0[1] + t * (s1[1] - s0[1]));
const g = Math.round(s0[2] + t * (s1[2] - s0[2]));
const b = Math.round(s0[3] + t * (s1[3] - s0[3]));
return { bg: `rgba(${r},${g},${b},0.18)`, fg: '#3a2a10' };
};
const deltaBg = (v) => {
if (v == null || isNaN(v)) return 'transparent';
if (Math.abs(v) < 1) return 'transparent';
const x = Math.min(1, Math.abs(v) / 12);
const a = 0.035 + x * 0.13;
return v > 0 ? `rgba(230,140,50,${a.toFixed(3)})` : `rgba(80,135,210,${a.toFixed(3)})`;
};
const burnBg = (mins, uv) => {
if (!isFinite(mins) || uv <= 0) return 'transparent';
if (mins >= 240) return 'transparent';
const x = Math.max(0, Math.min(1, (240 - mins) / 220));
return `rgba(210,70,50,${(0.04 + x * 0.15).toFixed(3)})`;
};
// ── THE REGISTRY ────────────────────────────────────────────────────────
// Order here IS the left-to-right column order in the normal view, and the
// top-to-bottom row order in the rotated view.
export function buildColumnDefs(ctx) {
const {
visibleCols, indoorMode, indoorManaged,
showDecimals, showUnits,
skinType, utciEnv, pollenType,
aqBeyond, aqBeyondNote,
} = ctx;
const fmt = (v, dp = 1) => v == null ? '—' : (showDecimals ? v.toFixed(dp) : String(Math.round(v)));
const u = (unit) => showUnits ? unit : '';
// A plain temperature column: heatmap background, dark text, "N°C" body.
const tempCol = (key, label, unit, group, get, opts = {}) => ({
key, label, unit, group,
visible: !!visibleCols[key],
...opts,
render: (r, prev, next, dir) => ({
content: get(r) != null ? `${fmt(get(r))}${u('°C')}` : '—',
style: {
color: airTempFontColor(),
background: (opts.pet ? petAirTempBg : airTempBg)(get(r), get(prev ?? {}), get(next ?? {}), dir),
},
title: opts.petTitle ? (petCategory(get(r))?.label ?? '') : undefined,
}),
});
const defs = [
{
key: 'utciP', label: 'SunSoak', unit: '°C felt', group: 'felt',
visible: !!visibleCols.utciP,
headExtra: UTCI_ENVIRONMENTS[utciEnv]?.shortLabel
? html`<span class="col-env-badge">${UTCI_ENVIRONMENTS[utciEnv].shortLabel}</span>`
: null,
render: (r, prev, next, dir) => ({
content: `${fmt(r.utciAdj)}${u('°C')}`,
style: { background: airTempBg(r.utciAdj, prev?.utciAdj, next?.utciAdj, dir), color: airTempFontColor(), fontSize: '13px' },
}),
},
tempCol('shadeT', 'Shade', '°C felt', 'felt', r => r.shadeT, { headClass: 'utci-tight-head' }),
tempCol('vehicleT', 'Vehicle', '°C peak', 'felt', r => r.vehicleT),
{
...tempCol('indoorT', 'Indoors', '°C est.', 'felt', r => r.indoorT),
visible: indoorMode === 'on' && !indoorManaged,
},
{
...tempCol('managedT', 'Managed', '°C est.', 'felt', r => r.managedT),
visible: indoorMode === 'on' && indoorManaged,
},
{
key: 'burn', label: 'Burn', unit: 'to MED', group: 'felt',
visible: !!visibleCols.burn,
render: (r) => {
const mins = sunburnMinutes(r.uv, skinType);
return {
content: burnLabel(mins),
style: {
color: r.uv > 0 ? (mins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218',
background: burnBg(mins, r.uv),
},
};
},
},
tempCol('utci', 'UTCI', '°C felt', 'felt', r => r.utci),
{
key: 'delta', label: 'Δ', unit: 'UTCIAir', group: 'felt',
visible: !!visibleCols.delta,
render: (r) => {
const d = r.utci - r.Ta;
return {
content: `${d > 0 ? '+' : ''}${fmt(d)}${u('°C')}`,
style: {
color: d > 3 ? '#c8601a' : d < -3 ? '#3f73c4' : '#4a3218',
background: deltaBg(d),
},
};
},
},
tempCol('tmrt', 'Tmrt', '°C', 'felt', r => r.Tmrt),
tempCol('concreteT', 'Concrete', '°C surface', 'surface', r => r.concreteT),
tempCol('furSurfaceT', 'Fur', '°C surface', 'surface', r => r.furSurfaceT, { pet: true, petTitle: true }),
tempCol('pawT', 'Paw', '°C surface', 'surface', r => r.pawT, { pet: true, petTitle: true }),
tempCol('soilT', 'Soil °C', 'surface', 'surface', r => r.soilT0),
tempCol('soilT6', 'Soil 6cm', '°C root', 'surface', r => r.soilT6),
{
key: 'soilM', label: 'Soil moist', unit: '%', group: 'surface',
visible: !!visibleCols.soilM,
render: (r) => {
const sm = soilMoistureBg(r.soilM);
return {
content: r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—',
style: { color: sm.fg, background: sm.bg },
};
},
},
tempCol('petShadeT', 'Pet Shade', '°C', 'ambient', r => r.petShadeT, { pet: true, petTitle: true, headClass: 'utci-tight-head' }),
// Pet Home reads the same indoorT figure as the Indoors column, just on
// the pet-calibrated colour scale.
tempCol('petHomeT', 'Pet Home', '°C est.', 'ambient', r => r.indoorT, { pet: true, petTitle: true }),
tempCol('air', 'Air', '°C', 'ambient', r => r.Ta, { headClass: 'utci-tight-head' }),
{
key: 'rh', label: 'RH', unit: '%', group: 'ambient',
visible: !!visibleCols.rh,
render: (r) => ({
content: `${Math.round(r.RH)}${u('%')}`,
style: { background: scaleBg(r.RH, 30, 100, '70,145,200') },
}),
},
tempCol('dew', 'Dew', '°C', 'ambient', r => r.dew),
{
key: 'precip', label: 'Pcpt', unit: 'mm/h', group: 'precip',
visible: !!visibleCols.precip, headClass: 'utci-tight-head',
render: (r) => ({
style: { background: r.snow > 0 ? scaleBg(r.snow, 0, 4, '90,140,210') : scaleBg(r.precip, 0, 8, '70,145,200') },
content: html`
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '5px', verticalAlign: 'middle' }}>
<${PrecipIcon} precip=${r.precipProb > 0 ? r.precip : 0} snow=${r.precipProb > 0 ? r.snow : 0} size=${28} />
<span style=${{ color: r.snow > 0 ? '#2a5fa8' : r.precip > 0 ? '#2a6a90' : '#7a5c30' }}>
${r.precipProb > 0
? (r.snow > 0 ? fmt(r.snow) + u('cm') : r.precip > 0 ? fmt(r.precip) + u('mm') : '—')
: '—'}
</span>
</span>`,
}),
},
{
key: 'precipProb', label: 'Rain', unit: '%', group: 'precip',
visible: !!visibleCols.precipProb, headClass: 'utci-tight-head',
render: (r) => ({
content: `${r.precipProb}${u('%')}`,
style: {
background: scaleBg(r.precipProb, 0, 100, '70,145,200'),
color: r.precipProb >= 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90',
},
}),
},
{
key: 'lightning', label: '⚡', unit: 'LPI', group: 'precip',
visible: !!visibleCols.lightning, headClass: 'utci-tight-head',
render: (r) => {
const lpi = r.lightning ?? 0;
return {
content: lpi > 0 ? `${Math.round(lpi)}` : '',
style: {
background: lpi >= 25 ? 'rgba(255,180,0,0.25)' : lpi >= 5 ? 'rgba(255,210,0,0.15)' : 'transparent',
color: lpi >= 25 ? '#7a4800' : lpi >= 5 ? '#6a5800' : '#7a8a90',
textAlign: 'center',
},
};
},
},
{
key: 'cloud', label: 'Cloud', unit: '%', group: 'sky',
visible: !!visibleCols.cloud,
render: (r) => ({
style: { background: scaleBg(r.cc, 0, 100, '110,130,150', 0.07), verticalAlign: 'middle' },
content: html`
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
<span style=${{ position: 'relative', top: '6px' }}>
<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} />
</span>
<span>${Math.round(r.cc)}${u('%')}</span>
</span>`,
}),
},
{
key: 'vis', label: 'Vis', unit: 'km', group: 'sky',
visible: !!visibleCols.vis, headClass: 'utci-tight-head',
render: (r) => {
const v = r.visKm;
return {
content: v != null ? fmt(v) + u('km') : '—',
style: {
background: v == null ? 'transparent'
: v < 1 ? 'rgba(180,80,80,0.18)'
: v < 4 ? 'rgba(210,140,50,0.15)'
: v < 10 ? 'rgba(200,190,80,0.12)'
: 'transparent',
color: v != null && v < 4 ? '#8a3a1a' : '#4a3218',
},
};
},
},
{
key: 'wind', label: 'Wind', unit: 'mph (gust)', group: 'wind',
visible: !!visibleCols.wind,
render: (r) => ({
style: { background: scaleBg((r.gust ?? r.va) * 2.237, 0, 40, '85,130,180') },
content: html`${Math.round(r.va * 2.237)}${u('mph')}${r.gust != null && r.gust > r.va + 0.5
? (gm => html`<span style=${{ marginLeft: '4px', fontWeight: gm >= 25 ? 700 : 'normal', opacity: gm >= 25 ? 1 : 0.65, color: gm >= 55 ? '#b81010' : gm >= 40 ? '#d44010' : gm >= 25 ? '#c47a00' : 'inherit' }}>(${Math.round(gm)}${u('mph')})</span>`)(r.gust * 2.237)
: ''}`,
}),
},
{
key: 'dir', label: 'Dir', unit: '-', group: 'wind',
visible: !!visibleCols.dir, headClass: 'utci-dir-cell', cellClass: 'utci-dir-cell',
render: (r) => ({
content: html`
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
<${WindVane} bearing=${r.wd} size=${28} />
<span class="wind-dir-label" style=${{ fontFamily: 'Manrope, sans-serif', fontSize: '11px', fontWeight: 700 }}>${r.compass.label}</span>
</span>`,
}),
},
{
key: 'aqi', label: 'AQI', unit: aqBeyond ? 'no data' : 'EU idx', unitNone: aqBeyond, group: 'airqual',
visible: !!visibleCols.aqi,
render: (r) => {
const v = r.aqi == null ? null : Math.round(r.aqi);
return {
title: v == null && aqBeyondNote ? aqBeyondNote : null,
content: v == null ? '—'
: v < 20 ? `${v} Good`
: v < 40 ? `${v} Fair`
: v < 60 ? `${v} Mod`
: v < 80 ? `${v} Poor`
: v < 100 ? `${v} V.Poor`
: `${v} Hazard`,
style: {
background: v == null ? 'transparent'
: v < 20 ? 'rgba(80,180,100,0.15)'
: v < 40 ? 'rgba(140,200,100,0.13)'
: v < 60 ? 'rgba(220,200,60,0.15)'
: v < 80 ? 'rgba(220,130,50,0.18)'
: v < 100 ? 'rgba(200,70,50,0.18)'
: 'rgba(160,30,100,0.20)',
color: v != null && v >= 60 ? '#7a2010' : v != null && v >= 40 ? '#7a4a10' : '#2a4a20',
fontWeight: v != null && v >= 60 ? 600 : 400,
},
};
},
},
{
key: 'pollen',
label: pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen', '') ?? 'Pollen',
unit: aqBeyond ? 'no data' : 'grains/m³', unitNone: aqBeyond, group: 'airqual',
visible: !!visibleCols.pollen,
render: (r) => {
const pollenMap = {
all_pollen: [r.grassPollen, r.birchPollen, r.alderPollen, r.mugwortPollen, r.olivePollen, r.ragweedPollen].reduce((s, x) => x != null ? s + x : s, null),
grass_pollen: r.grassPollen,
birch_pollen: r.birchPollen,
alder_pollen: r.alderPollen,
mugwort_pollen: r.mugwortPollen,
olive_pollen: r.olivePollen,
ragweed_pollen: r.ragweedPollen,
};
const v = pollenMap[pollenType] ?? null;
return {
title: v == null && aqBeyondNote ? aqBeyondNote : null,
content: v == null ? '—'
: v < 10 ? `${Math.round(v)} Low`
: v < 50 ? `${Math.round(v)} Mod`
: v < 200 ? `${Math.round(v)} High`
: `${Math.round(v)} V.High`,
style: {
background: v == null ? 'transparent'
: v < 10 ? 'transparent'
: v < 50 ? 'rgba(180,200,80,0.13)'
: v < 200 ? 'rgba(210,150,50,0.16)'
: 'rgba(200,70,50,0.18)',
color: v != null && v >= 200 ? '#8a2010' : v != null && v >= 50 ? '#7a4a10' : '#4a3218',
fontWeight: v != null && v >= 50 ? 600 : 400,
},
};
},
},
{
key: 'uvA', label: 'UV-A', unit: 'est. idx', group: 'solar',
visible: !!visibleCols.uvA,
render: (r) => ({
content: r.uvA > 0 ? fmt(r.uvA) + u(' idx') : '—',
style: { color: r.uvA > 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') },
}),
},
{
key: 'uvB', label: 'UV-B', unit: 'est. idx', group: 'solar',
visible: !!visibleCols.uvB,
render: (r) => ({
content: r.uvB > 0 ? fmt(r.uvB, 2) + u(' idx') : '—',
style: { color: r.uvB > 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') },
}),
},
{
key: 'sun', label: 'Sun', unit: 'elev°', group: 'solar',
visible: !!visibleCols.sun,
render: (r) => ({
content: r.elev > 0 ? fmt(r.elev) + u('°') : '—',
style: { background: scaleBg(r.elev > 0 ? r.elev : null, 0, 70, '225,160,45') },
}),
},
{
key: 'direct', label: 'Direct', unit: 'W/m²', group: 'solar',
visible: !!visibleCols.direct,
render: (r) => ({
content: `${Math.round(r.dir)}${u('W/m²')}`,
style: { background: scaleBg(r.dir, 0, 850, '230,155,35') },
}),
},
{
key: 'diffuse', label: 'Diffuse', unit: 'W/m²', group: 'solar',
visible: !!visibleCols.diffuse,
render: (r) => ({
content: `${Math.round(r.dif)}${u('W/m²')}`,
style: { background: scaleBg(r.dif, 0, 450, '230,190,70') },
}),
},
];
return defs;
}