// ════════════════════════════════════════════════════════════════════════ // 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 | pets | 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 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, }, tempCol('utci', 'UTCI', '°C felt', 'felt', r => r.utci), { 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('concreteT', 'Concrete', '°C surface', 'surface', r => r.concreteT), 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('furSurfaceT', 'Fur', '°C surface', 'pets', r => r.furSurfaceT, { pet: true, petTitle: true }), tempCol('pawT', 'Paw', '°C surface', 'pets', r => r.pawT, { pet: true, petTitle: true }), tempCol('petShadeT', 'Pet Shade', '°C', 'pets', 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.', 'pets', 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') : '—') : '—'} `, }), }, { // Rain % carries the storm warning too: rather than spend a whole // column on the Lightning Potential Index, an amber ⚡ rides alongside // the percentage whenever LPI clears the moderate-risk threshold (5), // going bold at the high-risk one (25). Same thresholds the old // Lightning column and the Lightning-risk insight use. key: 'precipProb', label: 'Rain', unit: '%', group: 'precip', visible: !!visibleCols.precipProb, headClass: 'utci-tight-head', render: (r) => { const lpi = r.lightning ?? 0; const storm = lpi >= 25 ? 'high' : lpi >= 5 ? 'mod' : null; return { title: storm ? `Lightning potential ${Math.round(lpi)} J/kg — ${storm === 'high' ? 'high' : 'moderate'} risk` : undefined, content: html`${r.precipProb}${u('%')}${storm ? html`` : ''}`, style: { background: storm === 'high' ? 'rgba(255,180,0,0.25)' : storm === 'mod' ? 'rgba(255,210,0,0.15)' : scaleBg(r.precipProb, 0, 100, '70,145,200'), color: r.precipProb >= 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90', }, }; }, }, { 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; }