Cycling hazards (new feature) Added cyclingRisk() in compute.js, which scores each hour 0, 1 or 2 (fine, care, risky) from gusts, wind, heavy rain, snow, ice, fog and lightning. Shows a ⚠ tag beside the hour in the table and the forecast cards, amber for care and red for risky, and it opens the event-tag popup with advice. At a glance now has a “Ride with care / Risky to ride” row, and the best cycling window leaves out hazard hours. When hours are grouped into 2h/3h/4h rows, bikeRisk takes the worst hour, and the reasons come from that same hour. Felt wind Added feltWindMs() in utils.js, which compresses strong wind on a log curve so SunSoak and Shade (felt) no longer over-chill in gales. “Why it feels like” uses the same softened wind, so its parts still add up to the shown value. Week Ahead / best day scoring Every profile is now judged over waking hours (7am–10pm) instead of daylight only, and home, vehicle, pets, airport, camping, festival and driver run round the clock. Cycling, urban walking and airport each get their own scoring. Airport weights wind and fog most, and urban walking uses a cooler comfort range. Leisure variants such as picnic, beach and dog walk now get a sunshine weight from cloud cover, in daylight only. Gusts over 31 mph count three times as much in the score and in the choice of the day’s icon. Days with no good window are still listed, with a low score and the reason shown in place of a time range. Each day’s popup now shows its top three influences instead of one. Simple view (cards + curve) The temperature curve always plots hourly data now, so it keeps its shape at 2h/3h/4h. Cards that cover several hours are centred over their whole block, and the curve draws a bracket and dashed guides over the hours each card covers. Detailed (rotated) table styling Cells are tighter (smaller padding and minimum widths), so more hours fit across. The hour row has a white background with a lighter rule under the header, and the time label gets extra top padding. Metric names and their units sit on one line, and gusts stack under the wind speed. Toolbar / profile row The “Quick” view button is renamed “Simple”, and the view toggle is bigger, with Edit columns dropping onto a new line on mobile. The profile row now shows a scene thumbnail of the active profile (DayTabs.js). In that row, the “Profile” and “Viewing” labels stack above their values, and Change is a solid 40px button in the row’s own colour. Config / data Dog Walking now shows the pet columns: fur surface, paw temperature and pet shade (config.js). data/tracking.json has 14 new lines of tracking data.
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 class="wind-gust" style=${{ 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;
|
||
}
|