482 lines
22 KiB
JavaScript
482 lines
22 KiB
JavaScript
// ════════════════════════════════════════════════════════════════════════
|
||
// 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;
|
||
}
|