4.9.3
Layout tweaks
This commit is contained in:
+173
-413
@@ -39,6 +39,7 @@ import { DayTabs } from './components/DayTabs.js';
|
||||
import { ConfigPanel } from './components/ConfigPanel.js';
|
||||
import { WelcomeModal } from './components/WelcomeModal.js';
|
||||
import { RestoreModal } from './components/RestoreModal.js';
|
||||
import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js';
|
||||
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay } from './compute.js';
|
||||
import { solarElevationDeg } from './physics.js';
|
||||
import { exportDayXls } from './export.js';
|
||||
@@ -232,6 +233,7 @@ export function UTCIForecast() {
|
||||
indoorMode, setIndoorMode,
|
||||
pollenType, setPollenTypeAndSave,
|
||||
utciEnv, setUtciEnv,
|
||||
tableRotated, toggleTableRotated,
|
||||
headStickyRef, headTrackRef, headTableRef,
|
||||
bodyScrollRef, bodyTableRef, tableWrapRef,
|
||||
colPopup, colPopupRef,
|
||||
@@ -435,6 +437,9 @@ export function UTCIForecast() {
|
||||
const g = GROUP_OF[key];
|
||||
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.
|
||||
// Each visible group gets one spanning cell; groups with no visible
|
||||
// columns are skipped entirely. Hour always gets a blank lead cell.
|
||||
@@ -444,84 +449,14 @@ export function UTCIForecast() {
|
||||
for (const g of groups) {
|
||||
const span = GROUP_ORDER[g].filter(isColVisible).length;
|
||||
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">${labels[g]}</th>`);
|
||||
cells.push(html`<th class=${`grp-label grp-label-${g} grp-${g}`} colspan=${span} scope="colgroup">${GROUP_LABELS[g]}</th>`);
|
||||
}
|
||||
return html`<tr class="grp-label-row">${cells}</tr>`;
|
||||
};
|
||||
|
||||
// Continuous temperature colour scale - hoisted so both the table columns
|
||||
// and the thermal stress legend can share the same function.
|
||||
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]],
|
||||
];
|
||||
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);
|
||||
|
||||
// Continuous temperature colour scale — airTempRgb / petAirTempRgb now
|
||||
// live in tableColumns.js alongside the cell renderers that use them, and
|
||||
// are imported at the top of this file for the thermal-stress legend.
|
||||
// ─── 3b. PANEL COMPUTATIONS ──────────────────────────────────────────
|
||||
// whyFeelsLike is derived below, after the playback rows are resolved, so
|
||||
// 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`)
|
||||
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}`
|
||||
: 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(
|
||||
days[selectedDay]?.rows ?? [],
|
||||
activeProfile,
|
||||
@@ -1424,370 +1371,183 @@ export function UTCIForecast() {
|
||||
</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}>
|
||||
<span class="utci-scroll-chevron left" aria-hidden="true">‹</span>
|
||||
<span class="utci-scroll-chevron right" aria-hidden="true">›</span>
|
||||
|
||||
|
||||
<div class="utci-thead-sticky" ref=${headStickyRef}>
|
||||
<div class="utci-thead-track" ref=${headTrackRef}>
|
||||
<table class="utci-table utci-table-head" ref=${headTableRef}>
|
||||
<thead>
|
||||
${groupLabelRow()}
|
||||
<tr>
|
||||
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>
|
||||
<span>Rows</span>
|
||||
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
||||
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
|
||||
</span>
|
||||
</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>`}
|
||||
${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>`}
|
||||
${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>`}
|
||||
${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>`}
|
||||
${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>`}
|
||||
${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>`}
|
||||
${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>`}
|
||||
${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>`}
|
||||
${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>`}
|
||||
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>
|
||||
<span>Rows</span>
|
||||
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
||||
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
|
||||
</span>
|
||||
${rotateBtn}
|
||||
</th>
|
||||
${visibleColumnDefs.map(d => html`
|
||||
<th key=${d.key} scope="col"
|
||||
class=${`col-info-th ${d.headClass ?? ''} ${groupStart(d.key)} ${groupColor(d.key)}`.replace(/\s+/g, ' ').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>`)}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
|
||||
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
|
||||
<tbody>
|
||||
${tableRows.map((r, rowIdx) => {
|
||||
const rPrev = tableRows[rowIdx - 1];
|
||||
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);
|
||||
const f = hourFlags(r);
|
||||
return html`
|
||||
<tr key=${r.iso}
|
||||
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
|
||||
<td class="utci-time">
|
||||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
|
||||
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} />
|
||||
<span>${localHHMM}</span>
|
||||
${(() => {
|
||||
const rowEvents = getCellTagEvents(selectedDayEvents, r);
|
||||
return 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>
|
||||
</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>`}
|
||||
class=${`${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.trim()}>
|
||||
<td class="utci-time">${timeCell(r)}</td>
|
||||
${visibleColumnDefs.map(d => {
|
||||
const c = d.render(r, tableRows[rowIdx - 1], tableRows[rowIdx + 1], 'to bottom');
|
||||
return html`<td key=${d.key}
|
||||
class=${`${d.cellClass ?? ''} ${groupStart(d.key)}`.trim()}
|
||||
title=${c.title ?? null}
|
||||
style=${c.style ?? null}>${c.content}</td>`;
|
||||
})}
|
||||
</tr>`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
})()}
|
||||
</div>
|
||||
${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0 || visible.length > 0) && html`
|
||||
<aside class="glance-rail">
|
||||
|
||||
Reference in New Issue
Block a user