From 7f3fbb18c7521a0b4d2ee15b5d789b95b243fdab Mon Sep 17 00:00:00 2001 From: fraxle Date: Thu, 6 Aug 2026 00:22:20 +0100 Subject: [PATCH] 4.9.3 Layout tweaks --- assets/css/header.css | 2 +- assets/js/app.js | 586 +++++++++--------------------- assets/js/hooks/useAppState.js | 16 + assets/js/hooks/useTableScroll.js | 19 +- assets/js/tableColumns.js | 493 +++++++++++++++++++++++++ 5 files changed, 699 insertions(+), 417 deletions(-) create mode 100644 assets/js/tableColumns.js diff --git a/assets/css/header.css b/assets/css/header.css index 1ce91d8..acc6d9b 100644 --- a/assets/css/header.css +++ b/assets/css/header.css @@ -30,7 +30,7 @@ .utci-loc-search { position: relative; margin-top: 18px; - width: 300px; + width: 380px; max-width: 100%; } diff --git a/assets/js/app.js b/assets/js/app.js index 0c86aeb..f395793 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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`${labels[g]}`); + cells.push(html`${GROUP_LABELS[g]}`); } return html`${cells}`; }; - // 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() { + ${(() => { + // ── 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` + + <${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} /> + ${localHHMM} + ${rowEvents.map((ev, idx) => html` + handleEventTagClick(rowEvents, idx, e)} + onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)} + onMouseLeave=${handleEventTagLeave} + role="button" tabIndex="0" aria-label=${ev.title} + >${ev.emoji} + `)} + `; + }; + + // 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` + `; + + // ── 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` +
+
+ + + + + ${tableRows.map(r => { + const f = hourFlags(r); + return html` + `; + })} + + + + ${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` + + + `} + + + ${tableRows.map((r, hi) => { + const c = d.render(r, tableRows[hi - 1], tableRows[hi + 1], 'to right'); + const f = hourFlags(r); + return html``; + })} + + `; + })} + +
handleThClick('hour', e)} + onMouseEnter=${(e) => handleThEnter('hour', e)} + onMouseLeave=${handleThLeave}> + ${glanceDate} + + e.stopPropagation()}> + <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> + + ${rotateBtn} + + + ${timeCell(r)} +
+ ${GROUP_LABELS[newGroup]} +
handleThClick(d.key, e)} + onMouseEnter=${(e) => handleThEnter(d.key, e)} + onMouseLeave=${handleThLeave}> + ${d.label} ${d.unit}${d.headExtra ?? null} + ${c.content}
+
+
`; + + // ── 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`
- +
${groupLabelRow()} - - ${visibleCols.utciP && html``} - ${visibleCols.shadeT && html``} - ${visibleCols.vehicleT && html``} - ${indoorMode === 'on' && !indoorManaged && html``} - ${indoorMode === 'on' && indoorManaged && html``} - ${visibleCols.burn && html``} - ${visibleCols.utci && html``} - ${visibleCols.delta && html``} - ${visibleCols.tmrt && html``} - ${visibleCols.concreteT && html``} - ${visibleCols.furSurfaceT && html``} - ${visibleCols.pawT && html``} - ${visibleCols.soilT && html``} - ${visibleCols.soilT6 && html``} - ${visibleCols.soilM && html``} - ${visibleCols.petShadeT && html``} - ${visibleCols.petHomeT && html``} - ${visibleCols.air && html``} - ${visibleCols.rh && html``} - ${visibleCols.dew && html``} - ${visibleCols.precip && html``} - ${visibleCols.precipProb && html``} - ${visibleCols.lightning && html``} - ${visibleCols.cloud && html``} - ${visibleCols.vis && html``} - ${visibleCols.wind && html``} - ${visibleCols.dir && html``} - ${visibleCols.aqi && html``} - ${visibleCols.pollen && html``} - ${visibleCols.uvA && html``} - ${visibleCols.uvB && html``} - ${visibleCols.sun && html``} - ${visibleCols.direct && html``} - ${visibleCols.diffuse && html``} + + ${visibleColumnDefs.map(d => html` + `)}
handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}> - Rows - e.stopPropagation()}> - <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> - - handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>SunSoak °C felt${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ? html`${UTCI_ENVIRONMENTS[utciEnv].shortLabel}` : null} handleThClick('shadeT', e)} onMouseEnter=${(e) => handleThEnter('shadeT', e)} onMouseLeave=${handleThLeave}>Shade °C felt handleThClick('vehicleT', e)} onMouseEnter=${(e) => handleThEnter('vehicleT', e)} onMouseLeave=${handleThLeave}>Vehicle °C peak handleThClick('indoorT', e)} onMouseEnter=${(e) => handleThEnter('indoorT', e)} onMouseLeave=${handleThLeave}>Indoors °C est. handleThClick('managedT', e)} onMouseEnter=${(e) => handleThEnter('managedT', e)} onMouseLeave=${handleThLeave}>Managed °C est. handleThClick('burn', e)} onMouseEnter=${(e) => handleThEnter('burn', e)} onMouseLeave=${handleThLeave}>Burn to MED handleThClick('utci', e)} onMouseEnter=${(e) => handleThEnter('utci', e)} onMouseLeave=${handleThLeave}>UTCI °C felt handleThClick('delta', e)} onMouseEnter=${(e) => handleThEnter('delta', e)} onMouseLeave=${handleThLeave}>Δ UTCI−Air handleThClick('tmrt', e)} onMouseEnter=${(e) => handleThEnter('tmrt', e)} onMouseLeave=${handleThLeave}>Tmrt °C handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete °C surface handleThClick('furSurfaceT', e)} onMouseEnter=${(e) => handleThEnter('furSurfaceT', e)} onMouseLeave=${handleThLeave}>Fur °C surface handleThClick('pawT', e)} onMouseEnter=${(e) => handleThEnter('pawT', e)} onMouseLeave=${handleThLeave}>Paw °C surface handleThClick('soilT', e)} onMouseEnter=${(e) => handleThEnter('soilT', e)} onMouseLeave=${handleThLeave}>Soil °C surface handleThClick('soilT6', e)} onMouseEnter=${(e) => handleThEnter('soilT6', e)} onMouseLeave=${handleThLeave}>Soil 6cm °C root handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist % handleThClick('petShadeT', e)} onMouseEnter=${(e) => handleThEnter('petShadeT', e)} onMouseLeave=${handleThLeave}>Pet Shade °C handleThClick('petHomeT', e)} onMouseEnter=${(e) => handleThEnter('petHomeT', e)} onMouseLeave=${handleThLeave}>Pet Home °C est. handleThClick('air', e)} onMouseEnter=${(e) => handleThEnter('air', e)} onMouseLeave=${handleThLeave}>Air °C handleThClick('rh', e)} onMouseEnter=${(e) => handleThEnter('rh', e)} onMouseLeave=${handleThLeave}>RH % handleThClick('dew', e)} onMouseEnter=${(e) => handleThEnter('dew', e)} onMouseLeave=${handleThLeave}>Dew °C handleThClick('precip', e)} onMouseEnter=${(e) => handleThEnter('precip', e)} onMouseLeave=${handleThLeave}>Pcpt mm/h handleThClick('precipProb', e)} onMouseEnter=${(e) => handleThEnter('precipProb', e)} onMouseLeave=${handleThLeave}>Rain % handleThClick('lightning', e)} onMouseEnter=${(e) => handleThEnter('lightning', e)} onMouseLeave=${handleThLeave}>⚡ LPI handleThClick('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud % handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Vis km handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind mph (gust) handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir - handleThClick('aqi', e)} onMouseEnter=${(e) => handleThEnter('aqi', e)} onMouseLeave=${handleThLeave}>AQI ${aqBeyond ? 'no data' : 'EU idx'} handleThClick('pollen', e)} onMouseEnter=${(e) => handleThEnter('pollen', e)} onMouseLeave=${handleThLeave}>${pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen','') ?? 'Pollen'} ${aqBeyond ? 'no data' : 'grains/m³'} handleThClick('uvA', e)} onMouseEnter=${(e) => handleThEnter('uvA', e)} onMouseLeave=${handleThLeave}>UV-A est. idx handleThClick('uvB', e)} onMouseEnter=${(e) => handleThEnter('uvB', e)} onMouseLeave=${handleThLeave}>UV-B est. idx handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun elev° handleThClick('direct', e)} onMouseEnter=${(e) => handleThEnter('direct', e)} onMouseLeave=${handleThLeave}>Direct W/m² handleThClick('diffuse', e)} onMouseEnter=${(e) => handleThEnter('diffuse', e)} onMouseLeave=${handleThLeave}>Diffuse W/m² handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}> + Rows + e.stopPropagation()}> + <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> + + ${rotateBtn} + handleThClick(d.key, e)} + onMouseEnter=${(e) => handleThEnter(d.key, e)} + onMouseLeave=${handleThLeave}> + ${d.label} ${d.unit}${d.headExtra ?? null} +
- +
${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` - - ${visibleCols.utciP && html` - `} - ${visibleCols.shadeT && html` - `} - ${visibleCols.vehicleT && html` - `} - ${indoorMode === 'on' && !indoorManaged && html` - `} - ${indoorMode === 'on' && indoorManaged && html` - `} - ${visibleCols.burn && html` - `} - ${visibleCols.utci && html` - `} - ${visibleCols.delta && html` - `} - ${visibleCols.tmrt && html``} - ${visibleCols.concreteT && html` - `} - ${visibleCols.furSurfaceT && html` - `} - ${visibleCols.pawT && html` - `} - ${visibleCols.soilT && html` - `} - ${visibleCols.soilT6 && html` - `} - ${visibleCols.soilM && html` - `} - ${visibleCols.petShadeT && html` - `} - ${visibleCols.petHomeT && html` - `} - ${visibleCols.air && html``} - ${visibleCols.rh && html``} - ${visibleCols.dew && html``} - ${visibleCols.precip && html` - `} - ${visibleCols.precipProb && html` - `} - ${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``; - })()} - ${visibleCols.cloud && html``} - ${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``; - })()} - ${visibleCols.wind && html``} - ${visibleCols.dir && html``} - ${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``; - })()} - ${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``; - })()} - ${visibleCols.uvA && html` - `} - ${visibleCols.uvB && html` - `} - ${visibleCols.sun && html``} - ${visibleCols.direct && html``} - ${visibleCols.diffuse && html``} + class=${`${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.trim()}> + + ${visibleColumnDefs.map(d => { + const c = d.render(r, tableRows[rowIdx - 1], tableRows[rowIdx + 1], 'to bottom'); + return html``; + })} `; })}
- - <${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} /> - ${localHHMM} - ${(() => { - const rowEvents = getCellTagEvents(selectedDayEvents, r); - return rowEvents.map((ev, idx) => html` - handleEventTagClick(rowEvents, idx, e)} - onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)} - onMouseLeave=${handleEventTagLeave} - role="button" tabIndex="0" aria-label=${ev.title} - >${ev.emoji} - `); - })()} - - - ${fmt(r.utciAdj)}${u('°C')} - - ${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'} - - ${fmt(r.vehicleT)}${u('°C')} - - ${fmt(r.indoorT)}${u('°C')} - - ${fmt(r.managedT)}${u('°C')} - 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins, r.uv) }}> - ${burnLabel(burnMins)} - - ${fmt(r.utci)}${u('°C')} - 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218', - background: deltaBg(delta), - }}> - ${delta > 0 ? '+' : ''}${fmt(delta)}${u('°C')} - ${fmt(r.Tmrt)}${u('°C')} - ${fmt(r.concreteT)}${u('°C')} - - ${fmt(r.furSurfaceT)}${u('°C')} - - ${fmt(r.pawT)}${u('°C')} - ${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}${r.soilT6 != null ? fmt(r.soilT6) + u('°C') : '—'} { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'} - ${r.petShadeT != null ? fmt(r.petShadeT) + u('°C') : '—'} - - ${fmt(r.indoorT)}${u('°C')} - ${fmt(r.Ta)}${u('°C')}${Math.round(r.RH)}${u('%')}${fmt(r.dew)}${u('°C')} 0 ? scaleBg(r.snow, 0, 4, '90,140,210') : scaleBg(r.precip, 0, 8, '70,145,200') }}> - - <${PrecipIcon} precip=${r.precipProb > 0 ? r.precip : 0} snow=${r.precipProb > 0 ? r.snow : 0} size=${28} /> - 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') : '—') - : '—'} - - - = 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90' }}> - ${r.precipProb}${u('%')} - - ${lpi > 0 ? `${Math.round(lpi)}` : ''} - - - - <${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} /> - - ${Math.round(r.cc)}${u('%')} - - - ${v != null ? fmt(v) + u('km') : '—'} - - ${Math.round(r.va * 2.237)}${u('mph')}${r.gust != null && r.gust > r.va + 0.5 - ? (gm => html`= 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')})`)(r.gust * 2.237) - : ''} - - - <${WindVane} bearing=${r.wd} size=${28} /> - ${r.compass.label} - - = 60 ? 600 : 400 }}>${label}= 50 ? 600 : 400 }}>${label} 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}> - ${r.uvA > 0 ? fmt(r.uvA) + u(' idx') : '—'} - 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}> - ${r.uvB > 0 ? fmt(r.uvB, 2) + u(' idx') : '—'} - 0 ? r.elev : null, 0, 70, '225,160,45') }}>${r.elev > 0 ? fmt(r.elev) + u('°') : '—'}${Math.round(r.dir)}${u('W/m²')}${Math.round(r.dif)}${u('W/m²')}${timeCell(r)}${c.content}
-
+ `; + })()} ${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0 || visible.length > 0) && html`