From 2869694634c425baef16a250c4ff12a8e6ac350a Mon Sep 17 00:00:00 2001 From: fraxle Date: Thu, 30 Jul 2026 23:09:42 +0100 Subject: [PATCH] 3.6 New hour selection Animated UI elements --- assets/css/table.css | 148 +++++++++++++++++++++++++++------ assets/js/app.js | 14 ++-- assets/js/components.js | 35 ++++++++ assets/js/config.js | 2 +- assets/js/hooks/useAppState.js | 6 +- 5 files changed, 165 insertions(+), 40 deletions(-) diff --git a/assets/css/table.css b/assets/css/table.css index f74f2e1..e744e4d 100644 --- a/assets/css/table.css +++ b/assets/css/table.css @@ -926,13 +926,61 @@ /* Whole Columns bar (Hours + column pills) stays collapsed behind the Edit columns / Hide Columns toggle in the toolbar row until opened. Scoped to detailed (table) view — quick view keeps its own reduced - col-toggles bar (Hours only) visible regardless of this state. */ -.table-main:not(.table-main--simple) .col-toggles { - display: none; + col-toggles bar (Hours only) visible regardless of this state. + + It slides rather than snapping: the wrapper is a 1-row grid animated + 0fr → 1fr, which is the only way to transition to an unknown content + height in CSS. The bar's own padding/top border have to collapse with + it, otherwise a 25px sliver of empty panel stays behind at 0fr. + + Note the wrapper carries key=${forecastView} in app.js. Quick view keeps + this bar expanded and detailed view collapses it, so on a view switch the + browser would otherwise animate that difference — the panel would flash + open and slide shut. Remounting gives it no previous value to transition + from, so the collapsed state just applies. */ +.col-toggles-wrap { + display: grid; + grid-template-rows: 1fr; } -.table-main:not(.table-main--simple) .col-toggles.col-toggles--open { - display: flex; +.table-main:not(.table-main--simple) .col-toggles-wrap { + grid-template-rows: 0fr; + transition: grid-template-rows 0.28s cubic-bezier(0.4, 0, 0.2, 1); +} + +.table-main:not(.table-main--simple) .col-toggles-wrap > .col-toggles { + overflow: hidden; + min-height: 0; + padding-top: 0; + padding-bottom: 0; + border-top-width: 0; + opacity: 0; + transition: + padding 0.28s cubic-bezier(0.4, 0, 0.2, 1), + border-top-width 0.28s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.18s ease; +} + +.table-main:not(.table-main--simple) .col-toggles-wrap--open { + grid-template-rows: 1fr; +} + +.table-main:not(.table-main--simple) .col-toggles-wrap--open > .col-toggles { + padding-top: 10px; + padding-bottom: 10px; + border-top-width: 1.5px; + opacity: 1; + transition: + padding 0.28s cubic-bezier(0.4, 0, 0.2, 1), + border-top-width 0.28s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.22s ease 0.06s; +} + +@media (prefers-reduced-motion: reduce) { + .table-main:not(.table-main--simple) .col-toggles-wrap, + .table-main:not(.table-main--simple) .col-toggles-wrap > .col-toggles { + transition: none; + } } @@ -1392,38 +1440,94 @@ 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. */ +/* ── ROW-INTERVAL STEPPER ──────────────────────────────────────────────── + "Rows [−] 3h [+]" — sets how many hours each table row covers + (clock-aligned buckets from midnight). Used twice: bare inside the "Hour" + header, where it stays put while the body scrolls, and with its label in + the simple-view toggle bar (see .fvt-interval below). */ .hour-interval { display: flex; justify-content: flex-start; - gap: 2px; margin-top: 4px; } -.hour-interval-btn { +.row-stepper { + display: inline-flex; + align-items: center; + gap: 3px; +} +.row-stepper-label { font-family: Manrope, sans-serif; - font-size: 10px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #7a5c2a; + margin-right: 2px; +} +.row-stepper-btn { + display: flex; + align-items: center; + justify-content: center; + min-width: 26px; + min-height: 26px; + padding: 0; + font-family: Manrope, sans-serif; + font-size: 14px; font-weight: 700; line-height: 1; - padding: 3px 4px; cursor: pointer; border: 1px solid #c9b08a; background: #f5edd6; color: #6b4228; border-radius: 3px; + touch-action: manipulation; + user-select: none; transition: background 0.12s, border-color 0.12s, color 0.12s; } -.hour-interval-btn:hover { +.row-stepper-btn:hover:not(:disabled) { border-color: #c8922a; background: #fef3dc; } -.hour-interval-btn.on { +.row-stepper-btn:active:not(:disabled) { background: #c8922a; border-color: #c8922a; color: #fff; } +.row-stepper-btn:disabled { + opacity: 0.35; + cursor: default; +} +/* Fixed width so the row doesn't jitter as the value changes. */ +.row-stepper-value { + min-width: 26px; + text-align: center; + font-family: Manrope, sans-serif; + font-size: 11px; + font-weight: 700; + line-height: 1; + color: #6b4228; + user-select: none; +} + +/* Roomier tap targets on touch-sized screens. The simple-view stepper gets + the full bump; the one in the Hour header stays tighter because that + column is pinned and its width is taken straight off the viewport. */ +@media (max-width: 640px) { + .row-stepper-btn { + min-width: 28px; + min-height: 28px; + font-size: 15px; + } + .fvt-interval .row-stepper-btn { + min-width: 34px; + min-height: 34px; + font-size: 16px; + } + .fvt-interval .row-stepper-value { + min-width: 30px; + font-size: 12px; + } +} /* ── COLUMN DESCRIPTION POPUP ─────────────────────────────────────────── Fixed-position card that appears below a clicked column header. @@ -2154,27 +2258,17 @@ margin-bottom: -1.5px; } -/* Interval buttons shown in the col-toggles bar in simple mode, pushed - to the far right since Columns/Edit controls are hidden there. */ +/* Row stepper shown in the col-toggles bar in simple mode, pushed to the + far right since Columns/Edit controls are hidden there. */ .fvt-interval { display: none; align-items: center; - gap: 2px; margin-left: auto; flex-shrink: 0; } .table-main--simple .fvt-interval { display: inline-flex; } -.fvt-interval-label { - font-family: Manrope, sans-serif; - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: #7a5c2a; - margin-right: 3px; -} /* Hide table-mode column controls when in simple mode */ .table-main--simple .col-toggles-label, diff --git a/assets/js/app.js b/assets/js/app.js index 57340f3..9f0ccc3 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -31,7 +31,7 @@ import { FUR_COLORS, confidenceBand, moonGlyph, skyFillForElev, } from './utils.js'; -import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js'; +import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill, RowStepper } from './components.js'; import { getCellTagEvents, getUpcomingEvents } from './events.js'; import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS, FILTER_PROFILES, variantIcons, deriveProfileMain } from './config.js'; import { useAppState } from './hooks/useAppState.js'; @@ -1103,10 +1103,10 @@ export function UTCIForecast() { `; })()} +
- Hours: - ${[1, 2, 3, 4].map(n => html``)} + <${RowStepper} value=${tableInterval} onChange=${setTableInterval} />
${isPro && html`<${Fragment}> @@ -1157,6 +1157,7 @@ export function UTCIForecast() {
+
@@ -1291,12 +1292,7 @@ export function UTCIForecast() { handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}> Hour e.stopPropagation()}> - ${[1, 2, 3, 4].map(n => html` - `)} + <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> ${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}`} diff --git a/assets/js/components.js b/assets/js/components.js index acb436e..5656182 100644 --- a/assets/js/components.js +++ b/assets/js/components.js @@ -7,6 +7,7 @@ // Exports (in order of appearance): // CustomSelect({ value, options, onChange, ... }) styled dropdown pill // VentPill({ checked, onChange, label, title }) checkbox fused to pill +// RowStepper({ value, onChange, showLabel }) table row-interval stepper // SkyScope({ elev, dt, glob, size }) small porthole per hour // WindVane({ bearing, size }) compass-arrow widget // CloudIcon({ category, size, elev, dt }) cloud/sun glyph @@ -257,6 +258,40 @@ export function VentPill({ checked, onChange, label, title, grpClass }) { `; } +// ------------------------------------------------------------------- +// ROWSTEPPER - "Rows [-] 3h [+]" +// ------------------------------------------------------------------- +// Sets how many hours each forecast row covers. NOT a lookahead: the +// value picks the clock-aligned bucket size used by aggregateRows() +// (1h = every hour, 4h = quarter-day blocks). +// +// "-" steps toward 1h (finer, more rows), "+" toward 4h (coarser). +// Direction follows the readout, so + always raises the number shown. +// +// props: +// value - 1..4 +// onChange - fn(n) +// showLabel - draw the "Rows" caption (off inside the Hour header, +// where the column title already supplies the context) +// min/max - bounds, so 6h/12h can be added without a redesign +// ------------------------------------------------------------------- +export function RowStepper({ value, onChange, showLabel = true, min = 1, max = 4 }) { + const v = Math.min(max, Math.max(min, Number(value) || min)); + const step = (n) => { const next = Math.min(max, Math.max(min, n)); if (next !== v) onChange(next); }; + return html` + + ${showLabel && html`Rows`} + + ${v}h + + `; +} + // ------------------------------------------------------------------- // SKYSCOPE - the little porthole circle next to each hour. // ------------------------------------------------------------------- diff --git a/assets/js/config.js b/assets/js/config.js index 4ca5812..deeda1f 100644 --- a/assets/js/config.js +++ b/assets/js/config.js @@ -251,7 +251,7 @@ export const workVariantKeys = ['farming', 'construction', 'market', 'windowclea // Tooltip text shown when the user clicks/hovers a table column header. export const COL_DESCRIPTIONS = { - hour: { title: 'Hour', desc: 'Local wall-clock time for this forecast row. Each row covers one hour.' }, + hour: { title: 'Hour', desc: 'Local wall-clock time for this forecast row. Each row covers the interval set by the Rows stepper, clock-aligned from midnight — at 3h, a row labelled 09:00 spans 09:00–11:59. Totals like rainfall are summed across the block, risk figures show the block\'s peak, and smooth readings like air temperature are averaged.' }, air: { title: 'Air Temperature', desc: 'Measured air temperature at 2 m above the ground. This is the standard thermometer reading — it does not account for sun, wind, or humidity.' }, shadeT: { title: 'Shade Air', desc: 'Estimated air temperature sitting in the typical shade of the selected Solar Model — building shadow, beach umbrella, tree canopy, boat panels, and so on. Sheltered, sun-warmed surroundings push it above the official open-ground Air figure on calm sunny hours; wind and cloud pull it back toward it. This is an estimate, not a measurement, and differs from the standardised 2 m Air reading.' }, rh: { title: 'Relative Humidity', desc: 'How much moisture the air holds relative to its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.' }, diff --git a/assets/js/hooks/useAppState.js b/assets/js/hooks/useAppState.js index 5abe0f4..84d5509 100644 --- a/assets/js/hooks/useAppState.js +++ b/assets/js/hooks/useAppState.js @@ -529,12 +529,12 @@ export function useAppState() { return next; }); - // Table row interval: 1 = every hour (default), 2/3/4 = clock-aligned buckets. + // Table row interval: 1 = every hour, 2/3/4 = clock-aligned buckets. Default 2. 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; } + try { const n = parseInt(localStorage.getItem('sunscope_table_interval'), 10); return [1, 2, 3, 4].includes(n) ? n : 2; } catch (e) { return 2; } }); const setTableInterval = (n) => setTableIntervalState(() => { - const next = [1, 2, 3, 4].includes(n) ? n : 1; + const next = [1, 2, 3, 4].includes(n) ? n : 2; try { localStorage.setItem('sunscope_table_interval', String(next)); } catch (e) { /* ignore */ } return next; });