Files
fraxle 71860b9dd9 5.4.0
Restructure
New Journal entries
Colour tabs update with wind
2026-08-31 15:12:45 +01:00

482 lines
22 KiB
JavaScript
Raw Permalink 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.
// Anchored to match the day tabs' band ramp at the cold end: -30 is the
// Extreme cold anchor, -20 the Arctic one and -10 the Freezing one, the same
// three points bandRampRgb() uses (see BAND COLOUR RAMP in utils.js). The
// scale used to stop at -10, so every reading below it clamped to one flat
// blue — Invercargill at -28 C coloured identically to -10 C in the table,
// and the whole Arctic and Extreme cold range was invisible in Quick,
// Detailed and Table alike. Pet columns inherit the fix for free: they route
// through petEquivHumanTemp(), which already maps pet -28/-18 onto human
// -20/-10 but had nothing to look up down there.
export const TEMP_STOPS = [
[-30, [125, 80, 180]],
[-20, [ 85, 125, 205]],
[-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;
}