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;
|
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 ───────────────────────────────────────────
|
/* ── COLUMN DESCRIPTION POPUP ───────────────────────────────────────────
|
||||||
Fixed-position card that appears below a clicked column header.
|
Fixed-position card that appears below a clicked column header.
|
||||||
JS sets left/top via inline style (centred on the clicked th). */
|
JS sets left/top via inline style (centred on the clicked th). */
|
||||||
|
|||||||
+18
-7
@@ -56,6 +56,7 @@ export function UTCIForecast() {
|
|||||||
visibleCols, setVisibleCols, toggleCol,
|
visibleCols, setVisibleCols, toggleCol,
|
||||||
showDecimals, toggleShowDecimals,
|
showDecimals, toggleShowDecimals,
|
||||||
showUnits, toggleShowUnits,
|
showUnits, toggleShowUnits,
|
||||||
|
tableInterval, setTableInterval,
|
||||||
activityOptions, placeOptions, workOptions,
|
activityOptions, placeOptions, workOptions,
|
||||||
activityValue, activityLabel, placeValue, placeLabel, workValue, workLabel,
|
activityValue, activityLabel, placeValue, placeLabel, workValue, workLabel,
|
||||||
skinType, setSkinType,
|
skinType, setSkinType,
|
||||||
@@ -79,7 +80,7 @@ export function UTCIForecast() {
|
|||||||
closePopup, closeEventTagPopup,
|
closePopup, closeEventTagPopup,
|
||||||
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
|
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
|
||||||
hourlyRows, days, utcOffsetMs,
|
hourlyRows, days, utcOffsetMs,
|
||||||
visible, nowLocalISO, currentRow, currentCat,
|
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
||||||
activeEvents, lensEvent, selectedDayEvents,
|
activeEvents, lensEvent, selectedDayEvents,
|
||||||
bannerIndex, bannerTransition, bannerPrevIndex,
|
bannerIndex, bannerTransition, bannerPrevIndex,
|
||||||
bannerVisible, bannerStageRef,
|
bannerVisible, bannerStageRef,
|
||||||
@@ -690,7 +691,17 @@ export function UTCIForecast() {
|
|||||||
<thead>
|
<thead>
|
||||||
${groupLabelRow()}
|
${groupLabelRow()}
|
||||||
<tr>
|
<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.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>`}
|
${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('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}>
|
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
|
||||||
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
|
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
|
||||||
<tbody>
|
<tbody>
|
||||||
${visible.map((r, rowIdx) => {
|
${tableRows.map((r, rowIdx) => {
|
||||||
const rPrev = visible[rowIdx - 1];
|
const rPrev = tableRows[rowIdx - 1];
|
||||||
const rNext = visible[rowIdx + 1];
|
const rNext = tableRows[rowIdx + 1];
|
||||||
const cat = utciCategory(r.utci);
|
const cat = utciCategory(r.utci);
|
||||||
const isNight = r.elev < 0;
|
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 delta = r.utci - r.Ta;
|
||||||
const adjCat = utciCategory(r.utciAdj);
|
const adjCat = utciCategory(r.utciAdj);
|
||||||
// Converts a band hex colour to rgba at the given alpha — used to
|
// Converts a band hex colour to rgba at the given alpha — used to
|
||||||
@@ -932,7 +943,7 @@ export function UTCIForecast() {
|
|||||||
</span>
|
</span>
|
||||||
</td>`}
|
</td>`}
|
||||||
${visibleCols.aqi && (() => {
|
${visibleCols.aqi && (() => {
|
||||||
const v = r.aqi;
|
const v = r.aqi == null ? null : Math.round(r.aqi);
|
||||||
const bg = v == null ? 'transparent'
|
const bg = v == null ? 'transparent'
|
||||||
: v < 20 ? 'rgba(80,180,100,0.15)'
|
: v < 20 ? 'rgba(80,180,100,0.15)'
|
||||||
: v < 40 ? 'rgba(140,200,100,0.13)'
|
: v < 40 ? 'rgba(140,200,100,0.13)'
|
||||||
|
|||||||
@@ -231,6 +231,80 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
|||||||
return { hourlyRows, days, utcOffsetMs };
|
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
|
// computeWhyFeelsLike(row, env) - Break down the felt-temp delta into its
|
||||||
// contributing factors for the "Why it feels like this" panel.
|
// 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.type === 'promo') return false;
|
||||||
if (ev.nightOnly && row.elev >= -5) return false;
|
if (ev.nightOnly && row.elev >= -5) return false;
|
||||||
if (ev.id === 'heat-spike' && row.elev < 0) 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 (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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
UTCI_ENVIRONMENTS, VARIANT_DEFAULT_ENV,
|
UTCI_ENVIRONMENTS, VARIANT_DEFAULT_ENV,
|
||||||
} from '../config.js';
|
} from '../config.js';
|
||||||
import { utciCategory } from '../utils.js';
|
import { utciCategory } from '../utils.js';
|
||||||
import { buildHourlyRows } from '../compute.js';
|
import { buildHourlyRows, aggregateRows } from '../compute.js';
|
||||||
import { useForecast } from './useForecast.js';
|
import { useForecast } from './useForecast.js';
|
||||||
import { useColumnPopup } from './useColumnPopup.js';
|
import { useColumnPopup } from './useColumnPopup.js';
|
||||||
import { useTableScroll } from './useTableScroll.js';
|
import { useTableScroll } from './useTableScroll.js';
|
||||||
@@ -293,6 +293,16 @@ export function useAppState() {
|
|||||||
return next;
|
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);
|
const searchTimeout = useRef(null);
|
||||||
|
|
||||||
// Derived selectors used by profile controls
|
// Derived selectors used by profile controls
|
||||||
@@ -404,6 +414,10 @@ export function useAppState() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const visible = days[selectedDay]?.rows || [];
|
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 nowLocalISO = new Date(now.getTime() + utcOffsetMs).toISOString().slice(0, 13);
|
||||||
const currentRow = hourlyRows.length > 0
|
const currentRow = hourlyRows.length > 0
|
||||||
@@ -601,6 +615,7 @@ export function useAppState() {
|
|||||||
utciEnv, setUtciEnv: setUtciEnvAndSave,
|
utciEnv, setUtciEnv: setUtciEnvAndSave,
|
||||||
showDecimals, toggleShowDecimals,
|
showDecimals, toggleShowDecimals,
|
||||||
showUnits, toggleShowUnits,
|
showUnits, toggleShowUnits,
|
||||||
|
tableInterval, setTableInterval,
|
||||||
// table refs
|
// table refs
|
||||||
headStickyRef, headTrackRef, headTableRef,
|
headStickyRef, headTrackRef, headTableRef,
|
||||||
bodyScrollRef, bodyTableRef, tableWrapRef,
|
bodyScrollRef, bodyTableRef, tableWrapRef,
|
||||||
@@ -617,7 +632,7 @@ export function useAppState() {
|
|||||||
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
|
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
|
||||||
// computation
|
// computation
|
||||||
hourlyRows, days, utcOffsetMs,
|
hourlyRows, days, utcOffsetMs,
|
||||||
visible, nowLocalISO, currentRow, currentCat,
|
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
||||||
// banner + events
|
// banner + events
|
||||||
activeEvents, lensEvent, selectedDayEvents,
|
activeEvents, lensEvent, selectedDayEvents,
|
||||||
bannerIndex, bannerTransition, bannerPrevIndex,
|
bannerIndex, bannerTransition, bannerPrevIndex,
|
||||||
|
|||||||
Reference in New Issue
Block a user