3.6
New hour selection Animated UI elements
This commit is contained in:
+121
-27
@@ -926,13 +926,61 @@
|
|||||||
/* Whole Columns bar (Hours + column pills) stays collapsed behind the
|
/* Whole Columns bar (Hours + column pills) stays collapsed behind the
|
||||||
Edit columns / Hide Columns toggle in the toolbar row until opened.
|
Edit columns / Hide Columns toggle in the toolbar row until opened.
|
||||||
Scoped to detailed (table) view — quick view keeps its own reduced
|
Scoped to detailed (table) view — quick view keeps its own reduced
|
||||||
col-toggles bar (Hours only) visible regardless of this state. */
|
col-toggles bar (Hours only) visible regardless of this state.
|
||||||
.table-main:not(.table-main--simple) .col-toggles {
|
|
||||||
display: none;
|
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 {
|
.table-main:not(.table-main--simple) .col-toggles-wrap {
|
||||||
display: flex;
|
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;
|
background: #e8d8a8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── HOUR-INTERVAL SELECTOR ──────────────────────────────────────────────
|
/* ── ROW-INTERVAL STEPPER ────────────────────────────────────────────────
|
||||||
Compact 1h/2h/3h/4h segmented control inside the "Hour" header. Buckets
|
"Rows [−] 3h [+]" — sets how many hours each table row covers
|
||||||
the table into multi-hour rows (clock-aligned from midnight). Lives in the
|
(clock-aligned buckets from midnight). Used twice: bare inside the "Hour"
|
||||||
header so it stays put while the body scrolls. */
|
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 {
|
.hour-interval {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 2px;
|
|
||||||
margin-top: 4px;
|
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-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;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 3px 4px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1px solid #c9b08a;
|
border: 1px solid #c9b08a;
|
||||||
background: #f5edd6;
|
background: #f5edd6;
|
||||||
color: #6b4228;
|
color: #6b4228;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
touch-action: manipulation;
|
||||||
|
user-select: none;
|
||||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
.hour-interval-btn:hover {
|
.row-stepper-btn:hover:not(:disabled) {
|
||||||
border-color: #c8922a;
|
border-color: #c8922a;
|
||||||
background: #fef3dc;
|
background: #fef3dc;
|
||||||
}
|
}
|
||||||
.hour-interval-btn.on {
|
.row-stepper-btn:active:not(:disabled) {
|
||||||
background: #c8922a;
|
background: #c8922a;
|
||||||
border-color: #c8922a;
|
border-color: #c8922a;
|
||||||
color: #fff;
|
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 ───────────────────────────────────────────
|
/* ── COLUMN DESCRIPTION POPUP ───────────────────────────────────────────
|
||||||
Fixed-position card that appears below a clicked column header.
|
Fixed-position card that appears below a clicked column header.
|
||||||
@@ -2154,27 +2258,17 @@
|
|||||||
margin-bottom: -1.5px;
|
margin-bottom: -1.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Interval buttons shown in the col-toggles bar in simple mode, pushed
|
/* Row stepper shown in the col-toggles bar in simple mode, pushed to the
|
||||||
to the far right since Columns/Edit controls are hidden there. */
|
far right since Columns/Edit controls are hidden there. */
|
||||||
.fvt-interval {
|
.fvt-interval {
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.table-main--simple .fvt-interval {
|
.table-main--simple .fvt-interval {
|
||||||
display: inline-flex;
|
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 */
|
/* Hide table-mode column controls when in simple mode */
|
||||||
.table-main--simple .col-toggles-label,
|
.table-main--simple .col-toggles-label,
|
||||||
|
|||||||
+5
-9
@@ -31,7 +31,7 @@ import {
|
|||||||
FUR_COLORS,
|
FUR_COLORS,
|
||||||
confidenceBand, moonGlyph, skyFillForElev,
|
confidenceBand, moonGlyph, skyFillForElev,
|
||||||
} from './utils.js';
|
} 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 { getCellTagEvents, getUpcomingEvents } from './events.js';
|
||||||
import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS, FILTER_PROFILES, variantIcons, deriveProfileMain } from './config.js';
|
import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS, FILTER_PROFILES, variantIcons, deriveProfileMain } from './config.js';
|
||||||
import { useAppState } from './hooks/useAppState.js';
|
import { useAppState } from './hooks/useAppState.js';
|
||||||
@@ -1103,10 +1103,10 @@ export function UTCIForecast() {
|
|||||||
`;
|
`;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
<div key=${forecastView} class=${'col-toggles-wrap' + (colTogglesOpen ? ' col-toggles-wrap--open' : '')}>
|
||||||
<div class=${'col-toggles' + (colTogglesOpen ? ' col-toggles--open' : '')}>
|
<div class=${'col-toggles' + (colTogglesOpen ? ' col-toggles--open' : '')}>
|
||||||
<span class="fvt-interval">
|
<span class="fvt-interval">
|
||||||
<span class="fvt-interval-label">Hours:</span>
|
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} />
|
||||||
${[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=${() => setTableInterval(n)}>${n}h</button>`)}
|
|
||||||
</span>
|
</span>
|
||||||
<div class="col-toggles-body">
|
<div class="col-toggles-body">
|
||||||
${isPro && html`<${Fragment}>
|
${isPro && html`<${Fragment}>
|
||||||
@@ -1157,6 +1157,7 @@ export function UTCIForecast() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="forecast-simple-wrap">
|
<div class="forecast-simple-wrap">
|
||||||
<div class="forecast-simple-scroll" ref=${fscScrollRef}>
|
<div class="forecast-simple-scroll" ref=${fscScrollRef}>
|
||||||
@@ -1291,12 +1292,7 @@ export function UTCIForecast() {
|
|||||||
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>
|
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>
|
||||||
<span>Hour</span>
|
<span>Hour</span>
|
||||||
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
||||||
${[1, 2, 3, 4].map(n => html`
|
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
|
||||||
<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>
|
</span>
|
||||||
</th>
|
</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>`}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// Exports (in order of appearance):
|
// Exports (in order of appearance):
|
||||||
// CustomSelect({ value, options, onChange, ... }) styled dropdown pill
|
// CustomSelect({ value, options, onChange, ... }) styled dropdown pill
|
||||||
// VentPill({ checked, onChange, label, title }) checkbox fused to 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
|
// SkyScope({ elev, dt, glob, size }) small porthole per hour
|
||||||
// WindVane({ bearing, size }) compass-arrow widget
|
// WindVane({ bearing, size }) compass-arrow widget
|
||||||
// CloudIcon({ category, size, elev, dt }) cloud/sun glyph
|
// CloudIcon({ category, size, elev, dt }) cloud/sun glyph
|
||||||
@@ -257,6 +258,40 @@ export function VentPill({ checked, onChange, label, title, grpClass }) {
|
|||||||
</label>`;
|
</label>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// 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`
|
||||||
|
<span class="row-stepper" role="group" aria-label="Row interval">
|
||||||
|
${showLabel && html`<span class="row-stepper-label">Rows</span>`}
|
||||||
|
<button type="button" class="row-stepper-btn" disabled=${v <= min}
|
||||||
|
title="Finer rows" aria-label="Finer rows - fewer hours per row"
|
||||||
|
onClick=${() => step(v - 1)}>−</button>
|
||||||
|
<span class="row-stepper-value" aria-live="polite"
|
||||||
|
title=${v === 1 ? 'Every hour' : `Every ${v} hours`}>${v}h</span>
|
||||||
|
<button type="button" class="row-stepper-btn" disabled=${v >= max}
|
||||||
|
title="Coarser rows" aria-label="Coarser rows - more hours per row"
|
||||||
|
onClick=${() => step(v + 1)}>+</button>
|
||||||
|
</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// SKYSCOPE - the little porthole circle next to each hour.
|
// SKYSCOPE - the little porthole circle next to each hour.
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|||||||
+1
-1
@@ -251,7 +251,7 @@ export const workVariantKeys = ['farming', 'construction', 'market', 'windowclea
|
|||||||
|
|
||||||
// Tooltip text shown when the user clicks/hovers a table column header.
|
// Tooltip text shown when the user clicks/hovers a table column header.
|
||||||
export const COL_DESCRIPTIONS = {
|
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.' },
|
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.' },
|
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.' },
|
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.' },
|
||||||
|
|||||||
@@ -529,12 +529,12 @@ export function useAppState() {
|
|||||||
return next;
|
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(() => {
|
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 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 */ }
|
try { localStorage.setItem('sunscope_table_interval', String(next)); } catch (e) { /* ignore */ }
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user