4.9.3
Layout tweaks
This commit is contained in:
@@ -30,7 +30,7 @@
|
|||||||
.utci-loc-search {
|
.utci-loc-search {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
width: 300px;
|
width: 380px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+165
-405
@@ -39,6 +39,7 @@ import { DayTabs } from './components/DayTabs.js';
|
|||||||
import { ConfigPanel } from './components/ConfigPanel.js';
|
import { ConfigPanel } from './components/ConfigPanel.js';
|
||||||
import { WelcomeModal } from './components/WelcomeModal.js';
|
import { WelcomeModal } from './components/WelcomeModal.js';
|
||||||
import { RestoreModal } from './components/RestoreModal.js';
|
import { RestoreModal } from './components/RestoreModal.js';
|
||||||
|
import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js';
|
||||||
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay } from './compute.js';
|
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay } from './compute.js';
|
||||||
import { solarElevationDeg } from './physics.js';
|
import { solarElevationDeg } from './physics.js';
|
||||||
import { exportDayXls } from './export.js';
|
import { exportDayXls } from './export.js';
|
||||||
@@ -232,6 +233,7 @@ export function UTCIForecast() {
|
|||||||
indoorMode, setIndoorMode,
|
indoorMode, setIndoorMode,
|
||||||
pollenType, setPollenTypeAndSave,
|
pollenType, setPollenTypeAndSave,
|
||||||
utciEnv, setUtciEnv,
|
utciEnv, setUtciEnv,
|
||||||
|
tableRotated, toggleTableRotated,
|
||||||
headStickyRef, headTrackRef, headTableRef,
|
headStickyRef, headTrackRef, headTableRef,
|
||||||
bodyScrollRef, bodyTableRef, tableWrapRef,
|
bodyScrollRef, bodyTableRef, tableWrapRef,
|
||||||
colPopup, colPopupRef,
|
colPopup, colPopupRef,
|
||||||
@@ -435,6 +437,9 @@ export function UTCIForecast() {
|
|||||||
const g = GROUP_OF[key];
|
const g = GROUP_OF[key];
|
||||||
return g ? `grp-${g}` : '';
|
return g ? `grp-${g}` : '';
|
||||||
};
|
};
|
||||||
|
// Display names for the column groups — used for the spanning labels
|
||||||
|
// above the columns normally, and for the divider rows when rotated.
|
||||||
|
const GROUP_LABELS = { felt: 'Felt', surface: 'Surface', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' };
|
||||||
// Builds the group label row above the column headers.
|
// Builds the group label row above the column headers.
|
||||||
// Each visible group gets one spanning cell; groups with no visible
|
// Each visible group gets one spanning cell; groups with no visible
|
||||||
// columns are skipped entirely. Hour always gets a blank lead cell.
|
// columns are skipped entirely. Hour always gets a blank lead cell.
|
||||||
@@ -444,84 +449,14 @@ export function UTCIForecast() {
|
|||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
const span = GROUP_ORDER[g].filter(isColVisible).length;
|
const span = GROUP_ORDER[g].filter(isColVisible).length;
|
||||||
if (span === 0) continue;
|
if (span === 0) continue;
|
||||||
const labels = { felt: 'Felt', surface: 'Surface', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' };
|
cells.push(html`<th class=${`grp-label grp-label-${g} grp-${g}`} colspan=${span} scope="colgroup">${GROUP_LABELS[g]}</th>`);
|
||||||
cells.push(html`<th class=${`grp-label grp-label-${g} grp-${g}`} colspan=${span} scope="colgroup">${labels[g]}</th>`);
|
|
||||||
}
|
}
|
||||||
return html`<tr class="grp-label-row">${cells}</tr>`;
|
return html`<tr class="grp-label-row">${cells}</tr>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Continuous temperature colour scale - hoisted so both the table columns
|
// Continuous temperature colour scale — airTempRgb / petAirTempRgb now
|
||||||
// and the thermal stress legend can share the same function.
|
// live in tableColumns.js alongside the cell renderers that use them, and
|
||||||
const TEMP_STOPS = [
|
// are imported at the top of this file for the thermal-stress legend.
|
||||||
[-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]],
|
|
||||||
];
|
|
||||||
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
|
|
||||||
// per-row top/bottom cell blending (petAirTempBg mirrors airTempBg
|
|
||||||
// below). 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const petAirTempRgb = (t, whiteMix = 0.58) => airTempRgb(petEquivHumanTemp(t), whiteMix);
|
|
||||||
|
|
||||||
// ─── 3b. PANEL COMPUTATIONS ──────────────────────────────────────────
|
// ─── 3b. PANEL COMPUTATIONS ──────────────────────────────────────────
|
||||||
// whyFeelsLike is derived below, after the playback rows are resolved, so
|
// whyFeelsLike is derived below, after the playback rows are resolved, so
|
||||||
// the panel can track the simulated instant during play/scrub (see panelRow).
|
// the panel can track the simulated instant during play/scrub (see panelRow).
|
||||||
@@ -571,6 +506,18 @@ export function UTCIForecast() {
|
|||||||
? `Air quality and pollen are only forecast to ${new Date(`${aqHorizon}T00:00:00Z`)
|
? `Air quality and pollen are only forecast to ${new Date(`${aqHorizon}T00:00:00Z`)
|
||||||
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}`
|
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}`
|
||||||
: null;
|
: null;
|
||||||
|
// ─── TABLE COLUMN REGISTRY ───────────────────────────────────────────
|
||||||
|
// One definition per metric (label, unit, group, colouring, formatting),
|
||||||
|
// shared by both table orientations — see tableColumns.js. The array
|
||||||
|
// order is the column order normally, and the row order when rotated.
|
||||||
|
const columnDefs = buildColumnDefs({
|
||||||
|
visibleCols, indoorMode, indoorManaged,
|
||||||
|
showDecimals, showUnits,
|
||||||
|
skinType, utciEnv, pollenType,
|
||||||
|
aqBeyond, aqBeyondNote,
|
||||||
|
});
|
||||||
|
const visibleColumnDefs = columnDefs.filter(d => d.visible);
|
||||||
|
|
||||||
const glanceSummary = computeGlanceSummary(
|
const glanceSummary = computeGlanceSummary(
|
||||||
days[selectedDay]?.rows ?? [],
|
days[selectedDay]?.rows ?? [],
|
||||||
activeProfile,
|
activeProfile,
|
||||||
@@ -1424,6 +1371,129 @@ export function UTCIForecast() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${(() => {
|
||||||
|
// ── THE HOURLY TABLE, IN EITHER ORIENTATION ──────────────
|
||||||
|
// Normally hours run down the page and metrics across it.
|
||||||
|
// Rotated, the axes swap: hours along the top, metrics down
|
||||||
|
// the side. Both read the same visibleColumnDefs registry
|
||||||
|
// (see tableColumns.js), so the cells are identical either
|
||||||
|
// way — only the axis they are laid out on changes.
|
||||||
|
|
||||||
|
// Is this hour "now", and is the sun below the horizon?
|
||||||
|
const hourFlags = (r) => ({
|
||||||
|
isNight: r.elev < 0,
|
||||||
|
isNow: r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The scope + "3pm" + event tags cell. Leads each row
|
||||||
|
// normally; heads each column when rotated.
|
||||||
|
const timeCell = (r) => {
|
||||||
|
// r.iso is the local wall-clock string from the API — slice it directly.
|
||||||
|
const h24 = parseInt(r.iso.slice(11, 13), 10);
|
||||||
|
const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`;
|
||||||
|
const rowEvents = getCellTagEvents(selectedDayEvents, r);
|
||||||
|
return html`
|
||||||
|
<span class="utci-time-inner">
|
||||||
|
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} />
|
||||||
|
<span>${localHHMM}</span>
|
||||||
|
${rowEvents.map((ev, idx) => html`
|
||||||
|
<span key=${ev.id} class="event-cell-tag"
|
||||||
|
onClick=${(e) => handleEventTagClick(rowEvents, idx, e)}
|
||||||
|
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)}
|
||||||
|
onMouseLeave=${handleEventTagLeave}
|
||||||
|
role="button" tabIndex="0" aria-label=${ev.title}
|
||||||
|
>${ev.emoji}</span>
|
||||||
|
`)}
|
||||||
|
</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lives in the table's top-left cell in both orientations.
|
||||||
|
// stopPropagation so it doesn't also fire the header cell's
|
||||||
|
// column-info popup, same as the row-interval stepper.
|
||||||
|
const rotateBtn = html`
|
||||||
|
<button type="button"
|
||||||
|
class=${`table-rotate-btn${tableRotated ? ' on' : ''}`}
|
||||||
|
title=${tableRotated ? 'Hours down the side' : 'Hours along the top'}
|
||||||
|
aria-label="Rotate table"
|
||||||
|
aria-pressed=${tableRotated ? 'true' : 'false'}
|
||||||
|
onClick=${(e) => { e.stopPropagation(); toggleTableRotated(); }}
|
||||||
|
>⭮</button>`;
|
||||||
|
|
||||||
|
// ── ROTATED: hours across the top, metrics down the side ──
|
||||||
|
// One table, not two: the sticky top row and sticky left
|
||||||
|
// column are pure CSS here, so none of the head/body width
|
||||||
|
// syncing in useTableScroll is needed (it is disabled while
|
||||||
|
// rotated).
|
||||||
|
if (tableRotated) return html`
|
||||||
|
<div class=${`utci-table-wrap is-rotated${showUnits ? '' : ' no-units'}`}>
|
||||||
|
<div class="utci-rotated-scroll">
|
||||||
|
<table class="utci-table utci-table-rotated">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="rot-corner col-info-th" scope="col"
|
||||||
|
onClick=${(e) => handleThClick('hour', e)}
|
||||||
|
onMouseEnter=${(e) => handleThEnter('hour', e)}
|
||||||
|
onMouseLeave=${handleThLeave}>
|
||||||
|
<span class="rot-corner-date">${glanceDate}</span>
|
||||||
|
<span class="rot-corner-controls">
|
||||||
|
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
||||||
|
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
|
||||||
|
</span>
|
||||||
|
${rotateBtn}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
${tableRows.map(r => {
|
||||||
|
const f = hourFlags(r);
|
||||||
|
return html`
|
||||||
|
<th key=${r.iso} scope="col"
|
||||||
|
class=${`rot-hour-th ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}>
|
||||||
|
${timeCell(r)}
|
||||||
|
</th>`;
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${visibleColumnDefs.map((d, i) => {
|
||||||
|
// A group heading row is emitted whenever the
|
||||||
|
// group changes, standing in for the spanning
|
||||||
|
// group labels above the columns normally.
|
||||||
|
const newGroup = d.group && d.group !== (i > 0 ? visibleColumnDefs[i - 1].group : null) ? d.group : null;
|
||||||
|
return html`
|
||||||
|
<${Fragment} key=${d.key}>
|
||||||
|
${newGroup && html`
|
||||||
|
<tr class="grp-label-row grp-label-row--rotated">
|
||||||
|
<th class=${`grp-label grp-label-${newGroup} grp-${newGroup}`} scope="rowgroup" colspan=${tableRows.length + 1}>
|
||||||
|
${GROUP_LABELS[newGroup]}
|
||||||
|
</th>
|
||||||
|
</tr>`}
|
||||||
|
<tr class="rot-metric-row">
|
||||||
|
<th scope="row"
|
||||||
|
class=${`rot-metric-label col-info-th ${groupColor(d.key)}`.trim()}
|
||||||
|
onClick=${(e) => handleThClick(d.key, e)}
|
||||||
|
onMouseEnter=${(e) => handleThEnter(d.key, e)}
|
||||||
|
onMouseLeave=${handleThLeave}>
|
||||||
|
${d.label} <span class=${d.unitNone ? 'col-unit col-unit--none' : 'col-unit'}>${d.unit}</span>${d.headExtra ?? null}
|
||||||
|
</th>
|
||||||
|
${tableRows.map((r, hi) => {
|
||||||
|
const c = d.render(r, tableRows[hi - 1], tableRows[hi + 1], 'to right');
|
||||||
|
const f = hourFlags(r);
|
||||||
|
return html`<td key=${r.iso}
|
||||||
|
class=${`${d.cellClass ?? ''} ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}
|
||||||
|
title=${c.title ?? null}
|
||||||
|
style=${c.style ?? null}>${c.content}</td>`;
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</${Fragment}>`;
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// ── NORMAL: hours down the side, metrics across the top ───
|
||||||
|
// Split into a sticky header table and a scrolling body
|
||||||
|
// table whose column widths useTableScroll keeps in step.
|
||||||
|
return html`
|
||||||
<div class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}${showUnits ? '' : ' no-units'}`} ref=${tableWrapRef}>
|
<div class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}${showUnits ? '' : ' no-units'}`} ref=${tableWrapRef}>
|
||||||
<span class="utci-scroll-chevron left" aria-hidden="true">‹</span>
|
<span class="utci-scroll-chevron left" aria-hidden="true">‹</span>
|
||||||
<span class="utci-scroll-chevron right" aria-hidden="true">›</span>
|
<span class="utci-scroll-chevron right" aria-hidden="true">›</span>
|
||||||
@@ -1439,41 +1509,16 @@ export function UTCIForecast() {
|
|||||||
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
||||||
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
|
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
|
||||||
</span>
|
</span>
|
||||||
|
${rotateBtn}
|
||||||
</th>
|
</th>
|
||||||
${visibleCols.utciP && html`<th class=${`col-info-th ${groupStart('utciP')} ${groupColor('utciP')}`} scope="col" onClick=${(e) => handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>SunSoak <span class="col-unit">°C felt</span>${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ? html`<span class="col-env-badge">${UTCI_ENVIRONMENTS[utciEnv].shortLabel}</span>` : null}</th>`}
|
${visibleColumnDefs.map(d => html`
|
||||||
${visibleCols.shadeT && html`<th class=${`utci-tight-head col-info-th ${groupStart('shadeT')} ${groupColor('shadeT')}`} scope="col" onClick=${(e) => handleThClick('shadeT', e)} onMouseEnter=${(e) => handleThEnter('shadeT', e)} onMouseLeave=${handleThLeave}>Shade <span class="col-unit">°C felt</span></th>`}
|
<th key=${d.key} scope="col"
|
||||||
${visibleCols.vehicleT && html`<th class=${`col-info-th ${groupStart('vehicleT')} ${groupColor('vehicleT')}`} scope="col" onClick=${(e) => handleThClick('vehicleT', e)} onMouseEnter=${(e) => handleThEnter('vehicleT', e)} onMouseLeave=${handleThLeave}>Vehicle <span class="col-unit">°C peak</span></th>`}
|
class=${`col-info-th ${d.headClass ?? ''} ${groupStart(d.key)} ${groupColor(d.key)}`.replace(/\s+/g, ' ').trim()}
|
||||||
${indoorMode === 'on' && !indoorManaged && html`<th class=${`col-info-th ${groupStart('indoorT')} ${groupColor('indoorT')}`} scope="col" onClick=${(e) => handleThClick('indoorT', e)} onMouseEnter=${(e) => handleThEnter('indoorT', e)} onMouseLeave=${handleThLeave}>Indoors <span class="col-unit">°C est.</span></th>`}
|
onClick=${(e) => handleThClick(d.key, e)}
|
||||||
${indoorMode === 'on' && indoorManaged && html`<th class=${`col-info-th ${groupStart('managedT')} ${groupColor('managedT')}`} scope="col" onClick=${(e) => handleThClick('managedT', e)} onMouseEnter=${(e) => handleThEnter('managedT', e)} onMouseLeave=${handleThLeave}>Managed <span class="col-unit">°C est.</span></th>`}
|
onMouseEnter=${(e) => handleThEnter(d.key, e)}
|
||||||
${visibleCols.burn && html`<th class=${`col-info-th ${groupStart('burn')} ${groupColor('burn')}`} scope="col" onClick=${(e) => handleThClick('burn', e)} onMouseEnter=${(e) => handleThEnter('burn', e)} onMouseLeave=${handleThLeave}>Burn <span class="col-unit">to MED</span></th>`}
|
onMouseLeave=${handleThLeave}>
|
||||||
${visibleCols.utci && html`<th class=${`col-info-th ${groupStart('utci')} ${groupColor('utci')}`} scope="col" onClick=${(e) => handleThClick('utci', e)} onMouseEnter=${(e) => handleThEnter('utci', e)} onMouseLeave=${handleThLeave}>UTCI <span class="col-unit">°C felt</span></th>`}
|
${d.label} <span class=${d.unitNone ? 'col-unit col-unit--none' : 'col-unit'}>${d.unit}</span>${d.headExtra ?? null}
|
||||||
${visibleCols.delta && html`<th class=${`col-info-th ${groupStart('delta')} ${groupColor('delta')}`} scope="col" onClick=${(e) => handleThClick('delta', e)} onMouseEnter=${(e) => handleThEnter('delta', e)} onMouseLeave=${handleThLeave}>Δ <span class="col-unit">UTCI−Air</span></th>`}
|
</th>`)}
|
||||||
${visibleCols.tmrt && html`<th class=${`col-info-th ${groupStart('tmrt')} ${groupColor('tmrt')}`} scope="col" onClick=${(e) => handleThClick('tmrt', e)} onMouseEnter=${(e) => handleThEnter('tmrt', e)} onMouseLeave=${handleThLeave}>Tmrt <span class="col-unit">°C</span></th>`}
|
|
||||||
${visibleCols.concreteT && html`<th class=${`col-info-th ${groupStart('concreteT')} ${groupColor('concreteT')}`} scope="col" onClick=${(e) => handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete <span class="col-unit">°C surface</span></th>`}
|
|
||||||
${visibleCols.furSurfaceT && html`<th class=${`col-info-th ${groupStart('furSurfaceT')} ${groupColor('furSurfaceT')}`} scope="col" onClick=${(e) => handleThClick('furSurfaceT', e)} onMouseEnter=${(e) => handleThEnter('furSurfaceT', e)} onMouseLeave=${handleThLeave}>Fur <span class="col-unit">°C surface</span></th>`}
|
|
||||||
${visibleCols.pawT && html`<th class=${`col-info-th ${groupStart('pawT')} ${groupColor('pawT')}`} scope="col" onClick=${(e) => handleThClick('pawT', e)} onMouseEnter=${(e) => handleThEnter('pawT', e)} onMouseLeave=${handleThLeave}>Paw <span class="col-unit">°C surface</span></th>`}
|
|
||||||
${visibleCols.soilT && html`<th class=${`col-info-th ${groupStart('soilT')} ${groupColor('soilT')}`} scope="col" onClick=${(e) => handleThClick('soilT', e)} onMouseEnter=${(e) => handleThEnter('soilT', e)} onMouseLeave=${handleThLeave}>Soil °C <span class="col-unit">surface</span></th>`}
|
|
||||||
${visibleCols.soilT6 && html`<th class=${`col-info-th ${groupStart('soilT6')} ${groupColor('soilT6')}`} scope="col" onClick=${(e) => handleThClick('soilT6', e)} onMouseEnter=${(e) => handleThEnter('soilT6', e)} onMouseLeave=${handleThLeave}>Soil 6cm <span class="col-unit">°C root</span></th>`}
|
|
||||||
${visibleCols.soilM && html`<th class=${`col-info-th ${groupStart('soilM')} ${groupColor('soilM')}`} scope="col" onClick=${(e) => handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist <span class="col-unit">%</span></th>`}
|
|
||||||
${visibleCols.petShadeT && html`<th class=${`utci-tight-head col-info-th ${groupStart('petShadeT')} ${groupColor('petShadeT')}`} scope="col" onClick=${(e) => handleThClick('petShadeT', e)} onMouseEnter=${(e) => handleThEnter('petShadeT', e)} onMouseLeave=${handleThLeave}>Pet Shade <span class="col-unit">°C</span></th>`}
|
|
||||||
${visibleCols.petHomeT && html`<th class=${`col-info-th ${groupStart('petHomeT')} ${groupColor('petHomeT')}`} scope="col" onClick=${(e) => handleThClick('petHomeT', e)} onMouseEnter=${(e) => handleThEnter('petHomeT', e)} onMouseLeave=${handleThLeave}>Pet Home <span class="col-unit">°C est.</span></th>`}
|
|
||||||
${visibleCols.air && html`<th class=${`utci-tight-head col-info-th ${groupStart('air')} ${groupColor('air')}`} scope="col" onClick=${(e) => handleThClick('air', e)} onMouseEnter=${(e) => handleThEnter('air', e)} onMouseLeave=${handleThLeave}>Air <span class="col-unit">°C</span></th>`}
|
|
||||||
${visibleCols.rh && html`<th class=${`col-info-th ${groupStart('rh')} ${groupColor('rh')}`} scope="col" onClick=${(e) => handleThClick('rh', e)} onMouseEnter=${(e) => handleThEnter('rh', e)} onMouseLeave=${handleThLeave}>RH <span class="col-unit">%</span></th>`}
|
|
||||||
${visibleCols.dew && html`<th class=${`col-info-th ${groupStart('dew')} ${groupColor('dew')}`} scope="col" onClick=${(e) => handleThClick('dew', e)} onMouseEnter=${(e) => handleThEnter('dew', e)} onMouseLeave=${handleThLeave}>Dew <span class="col-unit">°C</span></th>`}
|
|
||||||
${visibleCols.precip && html`<th class=${`utci-tight-head col-info-th ${groupStart('precip')} ${groupColor('precip')}`} scope="col" onClick=${(e) => handleThClick('precip', e)} onMouseEnter=${(e) => handleThEnter('precip', e)} onMouseLeave=${handleThLeave}>Pcpt <span class="col-unit">mm/h</span></th>`}
|
|
||||||
${visibleCols.precipProb && html`<th class=${`utci-tight-head col-info-th ${groupStart('precipProb')} ${groupColor('precipProb')}`} scope="col" onClick=${(e) => handleThClick('precipProb', e)} onMouseEnter=${(e) => handleThEnter('precipProb', e)} onMouseLeave=${handleThLeave}>Rain <span class="col-unit">%</span></th>`}
|
|
||||||
${visibleCols.lightning && html`<th class=${`utci-tight-head col-info-th ${groupStart('lightning')} ${groupColor('lightning')}`} scope="col" onClick=${(e) => handleThClick('lightning', e)} onMouseEnter=${(e) => handleThEnter('lightning', e)} onMouseLeave=${handleThLeave}>⚡ <span class="col-unit">LPI</span></th>`}
|
|
||||||
${visibleCols.cloud && html`<th class=${`col-info-th ${groupStart('cloud')} ${groupColor('cloud')}`} scope="col" onClick=${(e) => handleThClick('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud <span class="col-unit">%</span></th>`}
|
|
||||||
${visibleCols.vis && html`<th class=${`utci-tight-head col-info-th ${groupStart('vis')} ${groupColor('vis')}`} scope="col" onClick=${(e) => handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Vis <span class="col-unit">km</span></th>`}
|
|
||||||
${visibleCols.wind && html`<th class=${`col-info-th ${groupStart('wind')} ${groupColor('wind')}`} scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">mph (gust)</span></th>`}
|
|
||||||
${visibleCols.dir && html`<th class=${`utci-dir-cell col-info-th ${groupStart('dir')} ${groupColor('dir')}`} scope="col" onClick=${(e) => handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir <span class="col-unit">-</span></th>`}
|
|
||||||
${visibleCols.aqi && html`<th class=${`col-info-th ${groupStart('aqi')} ${groupColor('aqi')}`} scope="col" onClick=${(e) => handleThClick('aqi', e)} onMouseEnter=${(e) => handleThEnter('aqi', e)} onMouseLeave=${handleThLeave}>AQI <span class=${aqBeyond ? 'col-unit col-unit--none' : 'col-unit'}>${aqBeyond ? 'no data' : 'EU idx'}</span></th>`}
|
|
||||||
${visibleCols.pollen && html`<th class=${`col-info-th ${groupStart('pollen')} ${groupColor('pollen')}`} scope="col" onClick=${(e) => handleThClick('pollen', e)} onMouseEnter=${(e) => handleThEnter('pollen', e)} onMouseLeave=${handleThLeave}>${pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen','') ?? 'Pollen'} <span class=${aqBeyond ? 'col-unit col-unit--none' : 'col-unit'}>${aqBeyond ? 'no data' : 'grains/m³'}</span></th>`}
|
|
||||||
${visibleCols.uvA && html`<th class=${`col-info-th ${groupStart('uvA')} ${groupColor('uvA')}`} scope="col" onClick=${(e) => handleThClick('uvA', e)} onMouseEnter=${(e) => handleThEnter('uvA', e)} onMouseLeave=${handleThLeave}>UV-A <span class="col-unit">est. idx</span></th>`}
|
|
||||||
${visibleCols.uvB && html`<th class=${`col-info-th ${groupStart('uvB')} ${groupColor('uvB')}`} scope="col" onClick=${(e) => handleThClick('uvB', e)} onMouseEnter=${(e) => handleThEnter('uvB', e)} onMouseLeave=${handleThLeave}>UV-B <span class="col-unit">est. idx</span></th>`}
|
|
||||||
${visibleCols.sun && html`<th class=${`col-info-th ${groupStart('sun')} ${groupColor('sun')}`} scope="col" onClick=${(e) => handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun <span class="col-unit">elev°</span></th>`}
|
|
||||||
${visibleCols.direct && html`<th class=${`col-info-th ${groupStart('direct')} ${groupColor('direct')}`} scope="col" onClick=${(e) => handleThClick('direct', e)} onMouseEnter=${(e) => handleThEnter('direct', e)} onMouseLeave=${handleThLeave}>Direct <span class="col-unit">W/m²</span></th>`}
|
|
||||||
${visibleCols.diffuse && html`<th class=${`col-info-th ${groupStart('diffuse')} ${groupColor('diffuse')}`} scope="col" onClick=${(e) => handleThClick('diffuse', e)} onMouseEnter=${(e) => handleThEnter('diffuse', e)} onMouseLeave=${handleThLeave}>Diffuse <span class="col-unit">W/m²</span></th>`}
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
</table>
|
</table>
|
||||||
@@ -1484,310 +1529,25 @@ export function UTCIForecast() {
|
|||||||
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
|
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
|
||||||
<tbody>
|
<tbody>
|
||||||
${tableRows.map((r, rowIdx) => {
|
${tableRows.map((r, rowIdx) => {
|
||||||
const rPrev = tableRows[rowIdx - 1];
|
const f = hourFlags(r);
|
||||||
const rNext = tableRows[rowIdx + 1];
|
|
||||||
const cat = utciCategory(r.utci);
|
|
||||||
const isNight = r.elev < 0;
|
|
||||||
const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO;
|
|
||||||
const delta = r.utci - r.Ta;
|
|
||||||
const adjCat = utciCategory(r.utciAdj);
|
|
||||||
// Converts a band hex colour to rgba at the given alpha — used to
|
|
||||||
// tint the UTCI and UTCI+P cells directly from the band colour.
|
|
||||||
const hexToRgba = (hex, a) => {
|
|
||||||
const h = hex.replace('#', '');
|
|
||||||
const r = parseInt(h.slice(0,2),16);
|
|
||||||
const g = parseInt(h.slice(2,4),16);
|
|
||||||
const b = parseInt(h.slice(4,6),16);
|
|
||||||
return `rgba(${r},${g},${b},${a})`;
|
|
||||||
};
|
|
||||||
const fmt = (v, dp = 1) => v == null ? '—' : (showDecimals ? v.toFixed(dp) : String(Math.round(v)));
|
|
||||||
const u = (unit) => showUnits ? unit : '';
|
|
||||||
// Returns a background tint based on temperature value - all share the same
|
|
||||||
// Continuous temperature colour scale — used for all temp columns.
|
|
||||||
// airTempRgb(t) returns the whiteblended [r,g,b] for a temperature.
|
|
||||||
// airTempBg(t, tPrev, tNext) returns a top→bottom gradient that
|
|
||||||
// flows from the previous row's colour through this row's colour to
|
|
||||||
// the next row's colour, making the whole column one seamless heatmap.
|
|
||||||
// airTempRgb and TEMP_STOPS hoisted to component scope above.
|
|
||||||
const toRgb = (rgb) => rgb ? `rgb(${rgb[0]},${rgb[1]},${rgb[2]})` : 'transparent';
|
|
||||||
const airTempBg = (t, tPrev, tNext) => {
|
|
||||||
if (t == null) return 'transparent';
|
|
||||||
// Top colour = midpoint temp between prev row and this row.
|
|
||||||
// Bottom colour = midpoint temp between this row and next row.
|
|
||||||
// This guarantees the boundary colour is identical on both sides
|
|
||||||
// of every cell edge, giving a perfectly seamless column gradient.
|
|
||||||
const tTop = tPrev != null ? (tPrev + t) / 2 : t;
|
|
||||||
const tBot = tNext != null ? (t + tNext) / 2 : t;
|
|
||||||
return `linear-gradient(to bottom, ${toRgb(airTempRgb(tTop))} 0%, ${toRgb(airTempRgb(tBot))} 100%)`;
|
|
||||||
};
|
|
||||||
const airTempFontColor = () => '#1a1a1a';
|
|
||||||
// Returns any extra band styles (fontWeight, textShadow) for a given temp.
|
|
||||||
const bandExtras = (t) => {
|
|
||||||
const band = utciCategory(t);
|
|
||||||
if (!band) return {};
|
|
||||||
return {
|
|
||||||
...(band.fontWeight ? { fontWeight: band.fontWeight } : {}),
|
|
||||||
...(band.textShadow ? { textShadow: band.textShadow } : {}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
// Pet-specific columns (Fur Colour, Pet Shade, Pet Home, Paw) use
|
|
||||||
// petAirTempRgb instead of airTempRgb - identical gradient/blend
|
|
||||||
// mechanics to every other temp column, just a pet-calibrated
|
|
||||||
// scale so the same degree reading lands on a different shade.
|
|
||||||
const petAirTempBg = (t, tPrev, tNext) => {
|
|
||||||
if (t == null) return 'transparent';
|
|
||||||
const tTop = tPrev != null ? (tPrev + t) / 2 : t;
|
|
||||||
const tBot = tNext != null ? (t + tNext) / 2 : t;
|
|
||||||
return `linear-gradient(to bottom, ${toRgb(petAirTempRgb(tTop))} 0%, ${toRgb(petAirTempRgb(tBot))} 100%)`;
|
|
||||||
};
|
|
||||||
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]));
|
|
||||||
const lum = 0.299*r + 0.587*g + 0.114*b;
|
|
||||||
return { bg: `rgba(${r},${g},${b},0.18)`, fg: '#3a2a10' };
|
|
||||||
};
|
|
||||||
const deltaBg = (v) => {
|
|
||||||
if (v == null || isNaN(v)) return 'transparent';
|
|
||||||
if (Math.abs(v) < 1) return 'transparent';
|
|
||||||
const x = Math.min(1, Math.abs(v) / 12);
|
|
||||||
const a = 0.035 + x * 0.13;
|
|
||||||
return v > 0 ? `rgba(230,140,50,${a.toFixed(3)})` : `rgba(80,135,210,${a.toFixed(3)})`;
|
|
||||||
};
|
|
||||||
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)})`;
|
|
||||||
};
|
|
||||||
// r.iso is the local wall-clock string from the API — slice it directly.
|
|
||||||
const h24 = parseInt(r.iso.slice(11, 13), 10);
|
|
||||||
const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`;
|
|
||||||
const burnMins = sunburnMinutes(r.uv, skinType);
|
|
||||||
return html`
|
return html`
|
||||||
<tr key=${r.iso}
|
<tr key=${r.iso}
|
||||||
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
|
class=${`${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.trim()}>
|
||||||
<td class="utci-time">
|
<td class="utci-time">${timeCell(r)}</td>
|
||||||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
|
${visibleColumnDefs.map(d => {
|
||||||
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} />
|
const c = d.render(r, tableRows[rowIdx - 1], tableRows[rowIdx + 1], 'to bottom');
|
||||||
<span>${localHHMM}</span>
|
return html`<td key=${d.key}
|
||||||
${(() => {
|
class=${`${d.cellClass ?? ''} ${groupStart(d.key)}`.trim()}
|
||||||
const rowEvents = getCellTagEvents(selectedDayEvents, r);
|
title=${c.title ?? null}
|
||||||
return rowEvents.map((ev, idx) => html`
|
style=${c.style ?? null}>${c.content}</td>`;
|
||||||
<span key=${ev.id} class="event-cell-tag"
|
})}
|
||||||
onClick=${(e) => handleEventTagClick(rowEvents, idx, e)}
|
|
||||||
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)}
|
|
||||||
onMouseLeave=${handleEventTagLeave}
|
|
||||||
role="button" tabIndex="0" aria-label=${ev.title}
|
|
||||||
>${ev.emoji}</span>
|
|
||||||
`);
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
${visibleCols.utciP && html`
|
|
||||||
<td class=${groupStart('utciP')} style=${{ background: airTempBg(r.utciAdj, rPrev?.utciAdj, rNext?.utciAdj), color: airTempFontColor(), fontSize: '13px' }}>
|
|
||||||
${fmt(r.utciAdj)}${u('°C')}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.shadeT && html`
|
|
||||||
<td class=${groupStart('shadeT')} style=${{ color: airTempFontColor(), background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT) }}>
|
|
||||||
${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.vehicleT && html`
|
|
||||||
<td class=${groupStart('vehicleT')} style=${{ color: airTempFontColor(), background: airTempBg(r.vehicleT, rPrev?.vehicleT, rNext?.vehicleT) }}>
|
|
||||||
${fmt(r.vehicleT)}${u('°C')}
|
|
||||||
</td>`}
|
|
||||||
${indoorMode === 'on' && !indoorManaged && html`
|
|
||||||
<td class=${groupStart('indoorT')} style=${{ color: airTempFontColor(), background: airTempBg(r.indoorT, rPrev?.indoorT, rNext?.indoorT) }}>
|
|
||||||
${fmt(r.indoorT)}${u('°C')}
|
|
||||||
</td>`}
|
|
||||||
${indoorMode === 'on' && indoorManaged && html`
|
|
||||||
<td class=${groupStart('managedT')} style=${{ color: airTempFontColor(), background: airTempBg(r.managedT, rPrev?.managedT, rNext?.managedT) }}>
|
|
||||||
${fmt(r.managedT)}${u('°C')}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.burn && html`
|
|
||||||
<td class=${groupStart('burn')} style=${{ color: r.uv > 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins, r.uv) }}>
|
|
||||||
${burnLabel(burnMins)}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.utci && html`
|
|
||||||
<td class=${groupStart('utci')} style=${{ background: airTempBg(r.utci, rPrev?.utci, rNext?.utci), color: airTempFontColor() }}>
|
|
||||||
${fmt(r.utci)}${u('°C')}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.delta && html`
|
|
||||||
<td class=${groupStart('delta')} style=${{
|
|
||||||
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218',
|
|
||||||
background: deltaBg(delta),
|
|
||||||
}}>
|
|
||||||
${delta > 0 ? '+' : ''}${fmt(delta)}${u('°C')}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.tmrt && html`<td class=${groupStart('tmrt')} style=${{ background: airTempBg(r.Tmrt, rPrev?.Tmrt, rNext?.Tmrt), color: airTempFontColor() }}>${fmt(r.Tmrt)}${u('°C')}</td>`}
|
|
||||||
${visibleCols.concreteT && html`
|
|
||||||
<td class=${groupStart('concreteT')} style=${{ color: airTempFontColor(), background: airTempBg(r.concreteT, rPrev?.concreteT, rNext?.concreteT) }}>
|
|
||||||
${fmt(r.concreteT)}${u('°C')}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.furSurfaceT && html`
|
|
||||||
<td class=${groupStart('furSurfaceT')} title=${petCategory(r.furSurfaceT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.furSurfaceT, rPrev?.furSurfaceT, rNext?.furSurfaceT) }}>
|
|
||||||
${fmt(r.furSurfaceT)}${u('°C')}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.pawT && html`
|
|
||||||
<td class=${groupStart('pawT')} title=${petCategory(r.pawT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.pawT, rPrev?.pawT, rNext?.pawT) }}>
|
|
||||||
${fmt(r.pawT)}${u('°C')}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.soilT && html`
|
|
||||||
<td class=${groupStart('soilT')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT0, rPrev?.soilT0, rNext?.soilT0) }}>${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}</td>`}
|
|
||||||
${visibleCols.soilT6 && html`
|
|
||||||
<td class=${groupStart('soilT6')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT6, rPrev?.soilT6, rNext?.soilT6) }}>${r.soilT6 != null ? fmt(r.soilT6) + u('°C') : '—'}</td>`}
|
|
||||||
${visibleCols.soilM && html`
|
|
||||||
<td class=${groupStart('soilM')} style=${(() => { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}</td>`}
|
|
||||||
${visibleCols.petShadeT && html`
|
|
||||||
<td class=${groupStart('petShadeT')} title=${r.petShadeT != null ? (petCategory(r.petShadeT)?.label ?? '') : ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.petShadeT, rPrev?.petShadeT, rNext?.petShadeT) }}>
|
|
||||||
${r.petShadeT != null ? fmt(r.petShadeT) + u('°C') : '—'}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.petHomeT && html`
|
|
||||||
<td class=${groupStart('petHomeT')} title=${petCategory(r.indoorT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.indoorT, rPrev?.indoorT, rNext?.indoorT) }}>
|
|
||||||
${fmt(r.indoorT)}${u('°C')}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.air && html`<td class=${groupStart('air')} style=${{ background: airTempBg(r.Ta, rPrev?.Ta, rNext?.Ta), color: airTempFontColor() }}>${fmt(r.Ta)}${u('°C')}</td>`}
|
|
||||||
${visibleCols.rh && html`<td class=${groupStart('rh')} style=${{ background: scaleBg(r.RH, 30, 100, '70,145,200') }}>${Math.round(r.RH)}${u('%')}</td>`}
|
|
||||||
${visibleCols.dew && html`<td class=${groupStart('dew')} style=${{ background: airTempBg(r.dew, rPrev?.dew, rNext?.dew), color: airTempFontColor() }}>${fmt(r.dew)}${u('°C')}</td>`}
|
|
||||||
${visibleCols.precip && html`
|
|
||||||
<td class=${groupStart('precip')} style=${{ background: r.snow > 0 ? scaleBg(r.snow, 0, 4, '90,140,210') : scaleBg(r.precip, 0, 8, '70,145,200') }}>
|
|
||||||
<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>
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.precipProb && html`
|
|
||||||
<td class=${groupStart('precipProb')} style=${{ background: scaleBg(r.precipProb, 0, 100, '70,145,200'), color: r.precipProb >= 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90' }}>
|
|
||||||
${r.precipProb}${u('%')}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.lightning && (() => {
|
|
||||||
const lpi = r.lightning ?? 0;
|
|
||||||
const bg = lpi >= 25 ? 'rgba(255,180,0,0.25)'
|
|
||||||
: lpi >= 5 ? 'rgba(255,210,0,0.15)'
|
|
||||||
: 'transparent';
|
|
||||||
const color = lpi >= 25 ? '#7a4800' : lpi >= 5 ? '#6a5800' : '#7a8a90';
|
|
||||||
return html`<td class=${groupStart('lightning')} style=${{ background: bg, color, textAlign: 'center' }}>
|
|
||||||
${lpi > 0 ? `${Math.round(lpi)}` : ''}
|
|
||||||
</td>`;
|
|
||||||
})()}
|
|
||||||
${visibleCols.cloud && html`<td class=${groupStart('cloud')} style=${{ background: scaleBg(r.cc, 0, 100, '110,130,150', 0.07), verticalAlign: 'middle' }}>
|
|
||||||
<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>
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.vis && (() => {
|
|
||||||
const v = r.visKm;
|
|
||||||
const bg = 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';
|
|
||||||
const color = v != null && v < 4 ? '#8a3a1a' : '#4a3218';
|
|
||||||
return html`<td class=${groupStart('vis')} style=${{ background: bg, color }}>
|
|
||||||
${v != null ? fmt(v) + u('km') : '—'}
|
|
||||||
</td>`;
|
|
||||||
})()}
|
|
||||||
${visibleCols.wind && html`<td class=${groupStart('wind')} style=${{ background: scaleBg((r.gust ?? r.va) * 2.237, 0, 40, '85,130,180') }}>
|
|
||||||
${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)
|
|
||||||
: ''}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.dir && html`<td class=${`utci-dir-cell ${groupStart('dir')}`}>
|
|
||||||
<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>
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.aqi && (() => {
|
|
||||||
const v = r.aqi == null ? null : Math.round(r.aqi);
|
|
||||||
const bg = 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)';
|
|
||||||
const color = v != null && v >= 60 ? '#7a2010' : v != null && v >= 40 ? '#7a4a10' : '#2a4a20';
|
|
||||||
const label = 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`;
|
|
||||||
return html`<td class=${groupStart('aqi')} title=${v == null && aqBeyondNote ? aqBeyondNote : null} style=${{ background: bg, color, fontWeight: v != null && v >= 60 ? 600 : 400 }}>${label}</td>`;
|
|
||||||
})()}
|
|
||||||
${visibleCols.pollen && (() => {
|
|
||||||
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;
|
|
||||||
const bg = 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)';
|
|
||||||
const color = v != null && v >= 200 ? '#8a2010' : v != null && v >= 50 ? '#7a4a10' : '#4a3218';
|
|
||||||
const label = 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`;
|
|
||||||
return html`<td class=${groupStart('pollen')} title=${v == null && aqBeyondNote ? aqBeyondNote : null} style=${{ background: bg, color, fontWeight: v != null && v >= 50 ? 600 : 400 }}>${label}</td>`;
|
|
||||||
})()}
|
|
||||||
${visibleCols.uvA && html`
|
|
||||||
<td class=${groupStart('uvA')} style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}>
|
|
||||||
${r.uvA > 0 ? fmt(r.uvA) + u(' idx') : '—'}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.uvB && html`
|
|
||||||
<td class=${groupStart('uvB')} style=${{ color: r.uvB > 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}>
|
|
||||||
${r.uvB > 0 ? fmt(r.uvB, 2) + u(' idx') : '—'}
|
|
||||||
</td>`}
|
|
||||||
${visibleCols.sun && html`<td class=${groupStart('sun')} style=${{ background: scaleBg(r.elev > 0 ? r.elev : null, 0, 70, '225,160,45') }}>${r.elev > 0 ? fmt(r.elev) + u('°') : '—'}</td>`}
|
|
||||||
${visibleCols.direct && html`<td class=${groupStart('direct')} style=${{ background: scaleBg(r.dir, 0, 850, '230,155,35') }}>${Math.round(r.dir)}${u('W/m²')}</td>`}
|
|
||||||
${visibleCols.diffuse && html`<td class=${groupStart('diffuse')} style=${{ background: scaleBg(r.dif, 0, 450, '230,190,70') }}>${Math.round(r.dif)}${u('W/m²')}</td>`}
|
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0 || visible.length > 0) && html`
|
${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0 || visible.length > 0) && html`
|
||||||
<aside class="glance-rail">
|
<aside class="glance-rail">
|
||||||
|
|||||||
@@ -637,6 +637,18 @@ export function useAppState() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rotates the hourly table: off = hours down the side, on = hours along
|
||||||
|
// the top with the metrics down the side. Off by default — the tall
|
||||||
|
// layout is the one most people expect from a forecast table.
|
||||||
|
const [tableRotated, setTableRotated] = useState(() => {
|
||||||
|
try { return localStorage.getItem('sunscope_table_rotated') === '1'; } catch (e) { return false; }
|
||||||
|
});
|
||||||
|
const toggleTableRotated = () => setTableRotated(v => {
|
||||||
|
const next = !v;
|
||||||
|
try { localStorage.setItem('sunscope_table_rotated', next ? '1' : '0'); } catch (e) { /* ignore */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Table row interval: 1 = every hour, 2/3/4 = clock-aligned buckets. Default 2.
|
// Table row interval: 1 = every hour, 2/3/4 = clock-aligned buckets. Default 2.
|
||||||
const [tableInterval, setTableIntervalState] = useState(() => {
|
const [tableInterval, setTableIntervalState] = useState(() => {
|
||||||
try { const n = parseInt(localStorage.getItem('sunscope_table_interval'), 10); return [1, 2, 3, 4].includes(n) ? n : 2; } catch (e) { return 2; }
|
try { const n = parseInt(localStorage.getItem('sunscope_table_interval'), 10); return [1, 2, 3, 4].includes(n) ? n : 2; } catch (e) { return 2; }
|
||||||
@@ -741,6 +753,9 @@ export function useAppState() {
|
|||||||
headTableRef, bodyTableRef, bodyScrollRef, headTrackRef, tableWrapRef,
|
headTableRef, bodyTableRef, bodyScrollRef, headTrackRef, tableWrapRef,
|
||||||
forecast, visibleCols, selectedDay, skinType, vehicleType,
|
forecast, visibleCols, selectedDay, skinType, vehicleType,
|
||||||
indoorMode, indoorManaged, showDecimals, showUnits,
|
indoorMode, indoorManaged, showDecimals, showUnits,
|
||||||
|
// The rotated table is a single table with CSS-only sticky edges, so
|
||||||
|
// none of this hook's dual-table syncing applies to it.
|
||||||
|
rotated: tableRotated,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 8. GEOCODING SEARCH ───────────────────────────────────────────────
|
// ── 8. GEOCODING SEARCH ───────────────────────────────────────────────
|
||||||
@@ -888,6 +903,7 @@ export function useAppState() {
|
|||||||
panelOpen, openPanel, closePanel,
|
panelOpen, openPanel, closePanel,
|
||||||
showUnits, toggleShowUnits,
|
showUnits, toggleShowUnits,
|
||||||
tableInterval, setTableInterval,
|
tableInterval, setTableInterval,
|
||||||
|
tableRotated, toggleTableRotated,
|
||||||
forecastView, setForecastView,
|
forecastView, setForecastView,
|
||||||
// table refs
|
// table refs
|
||||||
headStickyRef, headTrackRef, headTableRef,
|
headStickyRef, headTrackRef, headTableRef,
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export function useTableScroll({
|
|||||||
indoorManaged,
|
indoorManaged,
|
||||||
showDecimals,
|
showDecimals,
|
||||||
showUnits,
|
showUnits,
|
||||||
|
// When the table is rotated it renders as ONE table whose sticky header
|
||||||
|
// row and sticky metric column are handled entirely in CSS. There is no
|
||||||
|
// second table to keep in step, so every effect below short-circuits.
|
||||||
|
rotated = false,
|
||||||
}) {
|
}) {
|
||||||
// --- SCROLL INDICATORS ---------------------------------------------
|
// --- SCROLL INDICATORS ---------------------------------------------
|
||||||
const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false);
|
const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false);
|
||||||
@@ -53,6 +57,7 @@ export function useTableScroll({
|
|||||||
|
|
||||||
// --- DRAG-TO-SCROLL ------------------------------------------------
|
// --- DRAG-TO-SCROLL ------------------------------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (rotated) return;
|
||||||
const el = bodyScrollRef.current;
|
const el = bodyScrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
let isDown = false;
|
let isDown = false;
|
||||||
@@ -99,12 +104,19 @@ export function useTableScroll({
|
|||||||
document.removeEventListener('mouseup', onMouseUp);
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
el.removeEventListener('scroll', updateTableScrollIndicators);
|
el.removeEventListener('scroll', updateTableScrollIndicators);
|
||||||
};
|
};
|
||||||
}, [forecast]);
|
}, [forecast, rotated]);
|
||||||
|
|
||||||
// Update indicators after layout sync (columns may have changed width)
|
// Update indicators after layout sync (columns may have changed width)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (rotated) {
|
||||||
|
// Nothing is scrolling under our control — clear the fades so they
|
||||||
|
// don't linger over the rotated table.
|
||||||
|
setTableCanScrollLeft(false);
|
||||||
|
setTableCanScrollRight(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateTableScrollIndicators();
|
updateTableScrollIndicators();
|
||||||
}, [forecast, visibleCols, selectedDay]);
|
}, [forecast, visibleCols, selectedDay, rotated]);
|
||||||
|
|
||||||
// --- BODY SCROLL HANDLER (called from JSX onScroll) ---------------
|
// --- BODY SCROLL HANDLER (called from JSX onScroll) ---------------
|
||||||
const handleBodyScroll = () => {
|
const handleBodyScroll = () => {
|
||||||
@@ -129,6 +141,7 @@ export function useTableScroll({
|
|||||||
// "shrink-to-fit then distribute" strategy. See the comments inside
|
// "shrink-to-fit then distribute" strategy. See the comments inside
|
||||||
// sync() for the algorithm.
|
// sync() for the algorithm.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
if (rotated) return;
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
const headTable = headTableRef.current;
|
const headTable = headTableRef.current;
|
||||||
const bodyTable = bodyTableRef.current;
|
const bodyTable = bodyTableRef.current;
|
||||||
@@ -233,7 +246,7 @@ export function useTableScroll({
|
|||||||
if (ro) ro.disconnect();
|
if (ro) ro.disconnect();
|
||||||
window.removeEventListener('resize', sync);
|
window.removeEventListener('resize', sync);
|
||||||
};
|
};
|
||||||
}, [forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, showDecimals, showUnits]);
|
}, [forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, showDecimals, showUnits, rotated]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tableCanScrollLeft,
|
tableCanScrollLeft,
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// 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 | 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 deltaBg = (v) => {
|
||||||
|
if (v == null || isNaN(v)) return 'transparent';
|
||||||
|
if (Math.abs(v) < 1) return 'transparent';
|
||||||
|
const x = Math.min(1, Math.abs(v) / 12);
|
||||||
|
const a = 0.035 + x * 0.13;
|
||||||
|
return v > 0 ? `rgba(230,140,50,${a.toFixed(3)})` : `rgba(80,135,210,${a.toFixed(3)})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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('utci', 'UTCI', '°C felt', 'felt', r => r.utci),
|
||||||
|
{
|
||||||
|
key: 'delta', label: 'Δ', unit: 'UTCI−Air', group: 'felt',
|
||||||
|
visible: !!visibleCols.delta,
|
||||||
|
render: (r) => {
|
||||||
|
const d = r.utci - r.Ta;
|
||||||
|
return {
|
||||||
|
content: `${d > 0 ? '+' : ''}${fmt(d)}${u('°C')}`,
|
||||||
|
style: {
|
||||||
|
color: d > 3 ? '#c8601a' : d < -3 ? '#3f73c4' : '#4a3218',
|
||||||
|
background: deltaBg(d),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tempCol('tmrt', 'Tmrt', '°C', 'felt', r => r.Tmrt),
|
||||||
|
tempCol('concreteT', 'Concrete', '°C surface', 'surface', r => r.concreteT),
|
||||||
|
tempCol('furSurfaceT', 'Fur', '°C surface', 'surface', r => r.furSurfaceT, { pet: true, petTitle: true }),
|
||||||
|
tempCol('pawT', 'Paw', '°C surface', 'surface', r => r.pawT, { pet: true, petTitle: true }),
|
||||||
|
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('petShadeT', 'Pet Shade', '°C', 'ambient', 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.', 'ambient', 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>`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'precipProb', label: 'Rain', unit: '%', group: 'precip',
|
||||||
|
visible: !!visibleCols.precipProb, headClass: 'utci-tight-head',
|
||||||
|
render: (r) => ({
|
||||||
|
content: `${r.precipProb}${u('%')}`,
|
||||||
|
style: {
|
||||||
|
background: scaleBg(r.precipProb, 0, 100, '70,145,200'),
|
||||||
|
color: r.precipProb >= 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lightning', label: '⚡', unit: 'LPI', group: 'precip',
|
||||||
|
visible: !!visibleCols.lightning, headClass: 'utci-tight-head',
|
||||||
|
render: (r) => {
|
||||||
|
const lpi = r.lightning ?? 0;
|
||||||
|
return {
|
||||||
|
content: lpi > 0 ? `${Math.round(lpi)}` : '',
|
||||||
|
style: {
|
||||||
|
background: lpi >= 25 ? 'rgba(255,180,0,0.25)' : lpi >= 5 ? 'rgba(255,210,0,0.15)' : 'transparent',
|
||||||
|
color: lpi >= 25 ? '#7a4800' : lpi >= 5 ? '#6a5800' : '#7a8a90',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user