Data table compact hours 1h, 2h, 3h or 4h. Calc max/min or average
This commit is contained in:
fraxle
2026-06-02 23:31:14 +01:00
parent 7b603867dd
commit 5a03077d9e
5 changed files with 147 additions and 11 deletions
+18 -7
View File
@@ -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() {
<thead>
${groupLabelRow()}
<tr>
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>Hour</th>
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>
<span>Hour</span>
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
${[1, 2, 3, 4].map(n => html`
<button key=${n} type="button"
class=${`hour-interval-btn${tableInterval === n ? ' on' : ''}`}
title=${n === 1 ? 'Every hour' : `Every ${n} hours`}
onClick=${(e) => { e.stopPropagation(); setTableInterval(n); }}
>${n}h</button>`)}
</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.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>`}
@@ -728,12 +739,12 @@ export function UTCIForecast() {
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
<tbody>
${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() {
</span>
</td>`}
${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)'
+74
View File
@@ -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.
+5 -2
View File
@@ -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;
});
+17 -2
View File
@@ -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,