// ════════════════════════════════════════════════════════════════════════
// 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`${UTCI_ENVIRONMENTS[utciEnv].shortLabel}`
: 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: 'UTCI−Air', 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`
<${PrecipIcon} precip=${r.precipProb > 0 ? r.precip : 0} snow=${r.precipProb > 0 ? r.snow : 0} size=${28} />
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') : '—')
: '—'}
`,
}),
},
{
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`
<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} />
${Math.round(r.cc)}${u('%')}
`,
}),
},
{
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`= 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')})`)(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`
<${WindVane} bearing=${r.wd} size=${28} />
${r.compass.label}
`,
}),
},
{
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;
}