diff --git a/assets/css/table.css b/assets/css/table.css index 9447554..95787ad 100644 --- a/assets/css/table.css +++ b/assets/css/table.css @@ -658,6 +658,39 @@ background: #e8d8a8; } +/* ── HOUR-INTERVAL SELECTOR ────────────────────────────────────────────── + Compact 1h/2h/3h/4h segmented control inside the "Hour" header. Buckets + the table into multi-hour rows (clock-aligned from midnight). Lives in the + header so it stays put while the body scrolls. */ +.hour-interval { + display: flex; + justify-content: flex-start; + gap: 2px; + margin-top: 4px; +} +.hour-interval-btn { + font-family: Manrope, sans-serif; + font-size: 10px; + font-weight: 700; + line-height: 1; + padding: 3px 4px; + cursor: pointer; + border: 1px solid #c9b08a; + background: #f5edd6; + color: #6b4228; + border-radius: 3px; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.hour-interval-btn:hover { + border-color: #c8922a; + background: #fef3dc; +} +.hour-interval-btn.on { + background: #c8922a; + border-color: #c8922a; + color: #fff; +} + /* ── COLUMN DESCRIPTION POPUP ─────────────────────────────────────────── Fixed-position card that appears below a clicked column header. JS sets left/top via inline style (centred on the clicked th). */ diff --git a/assets/js/app.js b/assets/js/app.js index f6b971f..bc90997 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -56,6 +56,7 @@ export function UTCIForecast() { visibleCols, setVisibleCols, toggleCol, showDecimals, toggleShowDecimals, showUnits, toggleShowUnits, + tableInterval, setTableInterval, activityOptions, placeOptions, workOptions, activityValue, activityLabel, placeValue, placeLabel, workValue, workLabel, skinType, setSkinType, @@ -79,7 +80,7 @@ export function UTCIForecast() { closePopup, closeEventTagPopup, tableCanScrollLeft, tableCanScrollRight, handleBodyScroll, hourlyRows, days, utcOffsetMs, - visible, nowLocalISO, currentRow, currentCat, + visible, tableRows, nowLocalISO, currentRow, currentCat, activeEvents, lensEvent, selectedDayEvents, bannerIndex, bannerTransition, bannerPrevIndex, bannerVisible, bannerStageRef, @@ -690,7 +691,17 @@ export function UTCIForecast() { ${groupLabelRow()} - handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>Hour + handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}> + Hour + e.stopPropagation()}> + ${[1, 2, 3, 4].map(n => html` + `)} + + ${visibleCols.utciP && html` handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>SunSoak °C felt${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ? html`${UTCI_ENVIRONMENTS[utciEnv].shortLabel}` : null}`} ${visibleCols.vehicleT && html` handleThClick('vehicleT', e)} onMouseEnter=${(e) => handleThEnter('vehicleT', e)} onMouseLeave=${handleThLeave}>Vehicle °C peak`} ${indoorMode === 'on' && !indoorManaged && html` handleThClick('indoorT', e)} onMouseEnter=${(e) => handleThEnter('indoorT', e)} onMouseLeave=${handleThLeave}>Indoors °C est.`} @@ -728,12 +739,12 @@ export function UTCIForecast() {
- ${visible.map((r, rowIdx) => { - const rPrev = visible[rowIdx - 1]; - const rNext = visible[rowIdx + 1]; + ${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.iso.slice(0, 13) === nowLocalISO; + 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 @@ -932,7 +943,7 @@ export function UTCIForecast() { `} ${visibleCols.aqi && (() => { - const v = r.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)' diff --git a/assets/js/compute.js b/assets/js/compute.js index 1901d3a..00791bc 100644 --- a/assets/js/compute.js +++ b/assets/js/compute.js @@ -231,6 +231,80 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v return { hourlyRows, days, utcOffsetMs }; } +// ------------------------------------------------------------------------ +// aggregateRows(rows, interval) - Compress a day's hourly rows into +// multi-hour buckets for the table view (1h / 2h / 3h / 4h). +// +// Buckets are CLOCK-ALIGNED from midnight, so a 2h view groups 00-01, +// 02-03, ...; 3h groups 00-02, 03-05, ...; etc. Each output row keeps the +// LANDING (first) hour's positional fields - iso, dt, solar elevation, +// global radiation, wind direction, sky/cloud category - so the little +// scope, clock label and wind vane all show the time the column lands on. +// +// Every other column is aggregated with the rule that fits its meaning: +// - SUM : precipitation totals (1mm/h x 3h = 3mm) +// - MAX : risk/peak columns (rain %, UV, gusts, cabin/surface/indoor) +// - MEAN : smooth continuous quantities (air temp, RH, wind, cloud, ...) +// - FELT : worst-case felt temp - the warmest hour if it reaches >=20C +// (heat is the concern), otherwise the coldest (cold concern) +// +// Derived render values (Delta, Burn) are recomputed downstream from the +// aggregated Air / UTCI / UV, so they stay consistent automatically. +// +// interval <= 1 returns the input untouched (zero behaviour change). +// ------------------------------------------------------------------------ +const AGG_MEAN = [ + 'Ta', 'RH', 'dew', 'va', 'dir', 'dif', 'cc', 'ccLow', 'ccMid', 'ccHigh', + 'soilT0', 'soilT6', 'soilM', 'Tmrt', 'eh', 'visKm', 'aqi', 'effectiveRad', + 'grassPollen', 'birchPollen', 'alderPollen', 'mugwortPollen', 'olivePollen', 'ragweedPollen', +]; +const AGG_MAX = [ + 'precipProb', 'uv', 'uvA', 'uvB', 'gust', + 'vehicleT', 'concreteT', 'indoorT', 'managedT', +]; +const AGG_SUM = ['precip', 'snow']; +const AGG_FELT = ['utci', 'utciAdj']; + +export function aggregateRows(rows, interval) { + if (!rows || rows.length === 0 || !interval || interval <= 1) return rows; + + // Numbers for a field across the group, skipping null / NaN. + const nums = (group, f) => group.map(r => r[f]).filter(v => v != null && !isNaN(v)); + const mean = (vals) => vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null; + const maxV = (vals) => vals.length ? Math.max(...vals) : null; + const felt = (vals) => { + if (!vals.length) return null; + const hi = Math.max(...vals); + return hi >= 20 ? hi : Math.min(...vals); + }; + + // Split rows into clock-aligned buckets by floor(localHour / interval). + const buckets = []; + let cur = null, curKey = null; + for (const r of rows) { + const hour = parseInt(r.iso.slice(11, 13), 10); + const key = Math.floor(hour / interval); + if (key !== curKey) { cur = []; buckets.push(cur); curKey = key; } + cur.push(r); + } + + return buckets.map(group => { + // Start from the landing row so positional fields (iso, dt, elev, glob, + // wd, compass, cloudCat) carry through unchanged, then overwrite the + // aggregatable columns. + const out = { ...group[0] }; + AGG_MEAN.forEach(f => { out[f] = mean(nums(group, f)); }); + AGG_MAX.forEach(f => { out[f] = maxV(nums(group, f)); }); + AGG_SUM.forEach(f => { out[f] = group.reduce((a, r) => a + (r[f] || 0), 0); }); + AGG_FELT.forEach(f => { out[f] = felt(nums(group, f)); }); + // Bucket span markers used by the table for "now" highlighting and for + // matching event cell-tags that fall on any hour within the bucket. + out.isoHours = group.map(r => r.iso.slice(0, 13)); + out.isoEnd = group[group.length - 1].iso; + return out; + }); +} + // ------------------------------------------------------------------------ // computeWhyFeelsLike(row, env) - Break down the felt-temp delta into its // contributing factors for the "Why it feels like this" panel. diff --git a/assets/js/events.js b/assets/js/events.js index 92bcda4..23cbb5b 100644 --- a/assets/js/events.js +++ b/assets/js/events.js @@ -67,9 +67,12 @@ export function getCellTagEvents(events, row) { if (ev.type === 'promo') return false; if (ev.nightOnly && row.elev >= -5) return false; if (ev.id === 'heat-spike' && row.elev < 0) return false; - // Sunset/sunrise events: only show icon during the actual twilight window + // Sunset/sunrise events: only show icon during the actual twilight window. + // For bucketed table rows the span is [row.iso .. row.isoEnd]; show the + // icon if the event window overlaps that span at all. if (ev.isoRange) { - if (row.iso < ev.isoRange[0] || row.iso > ev.isoRange[1]) return false; + const hi = row.isoEnd || row.iso; + if (hi < ev.isoRange[0] || row.iso > ev.isoRange[1]) return false; } return true; }); diff --git a/assets/js/hooks/useAppState.js b/assets/js/hooks/useAppState.js index 4443215..0ce3c44 100644 --- a/assets/js/hooks/useAppState.js +++ b/assets/js/hooks/useAppState.js @@ -30,7 +30,7 @@ import { UTCI_ENVIRONMENTS, VARIANT_DEFAULT_ENV, } from '../config.js'; import { utciCategory } from '../utils.js'; -import { buildHourlyRows } from '../compute.js'; +import { buildHourlyRows, aggregateRows } from '../compute.js'; import { useForecast } from './useForecast.js'; import { useColumnPopup } from './useColumnPopup.js'; import { useTableScroll } from './useTableScroll.js'; @@ -293,6 +293,16 @@ export function useAppState() { return next; }); + // Table row interval: 1 = every hour (default), 2/3/4 = clock-aligned buckets. + const [tableInterval, setTableIntervalState] = useState(() => { + try { const n = parseInt(localStorage.getItem('sunscope_table_interval'), 10); return [1, 2, 3, 4].includes(n) ? n : 3; } catch (e) { return 3; } + }); + const setTableInterval = (n) => setTableIntervalState(() => { + const next = [1, 2, 3, 4].includes(n) ? n : 1; + try { localStorage.setItem('sunscope_table_interval', String(next)); } catch (e) { /* ignore */ } + return next; + }); + const searchTimeout = useRef(null); // Derived selectors used by profile controls @@ -404,6 +414,10 @@ export function useAppState() { }); const visible = days[selectedDay]?.rows || []; + // Rows actually rendered in the table - bucketed by the interval selector. + // `visible` stays full-resolution so the dial, glance and event detection + // keep seeing every hour. + const tableRows = aggregateRows(visible, tableInterval); const nowLocalISO = new Date(now.getTime() + utcOffsetMs).toISOString().slice(0, 13); const currentRow = hourlyRows.length > 0 @@ -601,6 +615,7 @@ export function useAppState() { utciEnv, setUtciEnv: setUtciEnvAndSave, showDecimals, toggleShowDecimals, showUnits, toggleShowUnits, + tableInterval, setTableInterval, // table refs headStickyRef, headTrackRef, headTableRef, bodyScrollRef, bodyTableRef, tableWrapRef, @@ -617,7 +632,7 @@ export function useAppState() { tableCanScrollLeft, tableCanScrollRight, handleBodyScroll, // computation hourlyRows, days, utcOffsetMs, - visible, nowLocalISO, currentRow, currentCat, + visible, tableRows, nowLocalISO, currentRow, currentCat, // banner + events activeEvents, lensEvent, selectedDayEvents, bannerIndex, bannerTransition, bannerPrevIndex,