2.5
Data table compact hours 1h, 2h, 3h or 4h. Calc max/min or average
This commit is contained in:
@@ -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). */
|
||||
|
||||
+18
-7
@@ -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)'
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user