Files
sunscope/assets/js/tableColumns.js
T
fraxle df3ecdae35 5.1
Combine and relabel table view switch.
Remove and re-arrange some table columns
Group Pets together
2026-08-16 09:13:15 +01:00

471 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ════════════════════════════════════════════════════════════════════════
// 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`<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,
},
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`
<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>`,
}),
},
{
// 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`<span style=${{ marginLeft: '4px', fontWeight: storm === 'high' ? 700 : 'normal', opacity: storm === 'high' ? 1 : 0.75, color: storm === 'high' ? '#7a4800' : '#6a5800' }}>⚡</span>`
: ''}`,
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`
<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;
}