3.7
Updated event banner to fit top screen Updated table thermal colours to max hot and min cold for 2h, 3h & 4h Ground Lab preview
This commit is contained in:
+153
-1
@@ -94,7 +94,7 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 18px 32px;
|
||||
padding: 0px 32px;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
@@ -188,6 +188,158 @@ body {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ── Event note ─────────────────────────────────────────────────────── */
|
||||
/* Active cosmic/weather events shown as a strip across the content width,
|
||||
above the nav. It is IN FLOW — it pushes the page down instead of
|
||||
covering anything, which is the whole point of it not being a flyout.
|
||||
One event at a time with its full message, cycling with a fade.
|
||||
|
||||
The nav is normally position:absolute and .utci-app reserves 52px of top
|
||||
padding for it. When a note is present the nav joins the flow instead so
|
||||
the note can sit above it, and that padding is no longer needed. On true
|
||||
mobile (≤640px) the nav is position:fixed and stays that way — there the
|
||||
note simply becomes the first thing under it, so no override applies. */
|
||||
.event-note {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 11px;
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 14px;
|
||||
background: #fdf8ee;
|
||||
border: 1.5px solid #c9b08a;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
animation: event-note-in 0.35s cubic-bezier(0.34, 1.25, 0.64, 1) both;
|
||||
}
|
||||
|
||||
@keyframes event-note-in {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Per-event colour, kept to a hairline so the strip stays parchment —
|
||||
same device as .almanac-entry-accent. */
|
||||
.event-note-accent {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Safety-relevant weather (heat, frost, storm, wind, wet) — brass emphasis */
|
||||
.event-note.is-priority {
|
||||
background: #f0e4c4;
|
||||
border-color: #c8922a;
|
||||
}
|
||||
|
||||
.event-note-slide {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
.event-note-slide.is-fading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.event-note-emoji {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-note-title {
|
||||
font-family: Fraunces, serif;
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: #2a1a08;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-note-msg {
|
||||
font-family: Manrope, sans-serif;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
color: #6b4f2a;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-note-dates {
|
||||
font-family: Manrope, sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9a7d5a;
|
||||
white-space: nowrap;
|
||||
margin-left: auto; /* push the dates to the right-hand end */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.event-note.is-priority .event-note-dates {
|
||||
color: #a06e18;
|
||||
}
|
||||
|
||||
.event-note-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
.event-note-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #d9c39b;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
.event-note-dot:hover {
|
||||
background: #c0a880;
|
||||
}
|
||||
.event-note-dot.active {
|
||||
background: #c8922a;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
/* Above true-mobile: the nav drops into the flow beneath the note. Its own
|
||||
32px side padding is dropped so the links line up with the note and the
|
||||
content rather than sitting 32px inside them. */
|
||||
@media (min-width: 641px) {
|
||||
.utci-app.has-event-note {
|
||||
padding-top: 18px;
|
||||
}
|
||||
/* `relative`, not `static` — the nav keeps its z-index:100 that way, so
|
||||
the About dropdown still paints over .utci-shell (which is z-index:1). */
|
||||
.utci-app.has-event-note .utci-topnav {
|
||||
position: relative;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.event-note {
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.event-note-dates {
|
||||
margin-left: 0; /* no room to right-align on a phone */
|
||||
}
|
||||
}
|
||||
|
||||
/* Nav logo — hidden on desktop, shown on mobile */
|
||||
.nav-logo {
|
||||
display: none;
|
||||
|
||||
@@ -2,136 +2,6 @@
|
||||
ui.css — Status banners, legend, about block, footer, and responsive.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── EVENT BANNER ───────────────────────────────────────────────────── */
|
||||
/* Animated slide-down banner for cosmic/weather/promo events. */
|
||||
|
||||
/* Flyout: a fixed panel pinned to the very top of the screen that slides
|
||||
down over everything (including the nav) and retracts upward on close. */
|
||||
.event-banner-wrap {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 14px;
|
||||
transform: translateY(-130%);
|
||||
transition: transform 0.5s cubic-bezier(0.34, 1.25, 0.64, 1);
|
||||
/* The wrap is full-width but the panel is centred — keep the wrap itself
|
||||
click-through so its transparent sides never block the nav beneath it.
|
||||
Only the visible banner panel (.event-banner) captures clicks. */
|
||||
pointer-events: none;
|
||||
}
|
||||
.event-banner-wrap.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Slide crossfade: both banners rendered simultaneously, fading into each other */
|
||||
@keyframes banner-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes banner-fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Container that holds both slides during a crossfade. Constrains the
|
||||
flyout to a centred panel width and clips the absolute outgoing slide. */
|
||||
.event-banner-stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
/* True crossfade — outgoing slide sits absolutely on top and fades out */
|
||||
.event-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 0 0 12px 12px; /* rounded bottom — it hangs from the top edge */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-left: 4px solid rgba(255,255,255,0.25);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer; /* whole banner is click-to-close */
|
||||
pointer-events: auto; /* re-enable clicks on the panel itself */
|
||||
}
|
||||
.event-banner--outgoing {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
animation: banner-fade-out 0.45s ease both;
|
||||
}
|
||||
.event-banner-emoji {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.event-banner-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.event-banner-title {
|
||||
font-family: Fraunces, serif;
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.event-banner-msg {
|
||||
font-family: Manrope, sans-serif;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
opacity: 0.88;
|
||||
}
|
||||
.event-banner-dismiss {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
opacity: 0.55;
|
||||
padding: 4px 6px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.event-banner-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Slideshow dots */
|
||||
.event-banner-dots {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 7px;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
}
|
||||
.event-banner-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.35;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.event-banner-dot.active {
|
||||
opacity: 1;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
.event-banner-dot:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Cell tag — event icon in the hour column */
|
||||
.event-cell-tag {
|
||||
font-size: 15px;
|
||||
@@ -312,46 +182,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── EVENT BANNER — mobile fixes ────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.event-banner-wrap {
|
||||
padding: 0; /* full-bleed flyout on small screens */
|
||||
}
|
||||
|
||||
.event-banner {
|
||||
align-items: flex-start; /* stop dismiss ✕ drifting downward */
|
||||
gap: 10px;
|
||||
padding: 18px 12px 14px 14px;
|
||||
margin-bottom: 0;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.event-banner-emoji {
|
||||
font-size: 20px;
|
||||
margin-top: 3px; /* visually balance the dates-line margin-top below */
|
||||
}
|
||||
|
||||
.event-banner-title {
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.event-banner-msg {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.event-banner-dismiss {
|
||||
margin-top: 0; /* keep ✕ pinned flush to the top-right */
|
||||
padding: 0 2px;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── CUSTOM SELECT DROPDOWN ─────────────────────────────────────────── */
|
||||
/* Cross-platform replacement for native <select> on the col-toggles bar */
|
||||
|
||||
|
||||
+79
-80
@@ -32,7 +32,7 @@ import {
|
||||
confidenceBand, moonGlyph, skyFillForElev,
|
||||
} from './utils.js';
|
||||
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill, RowStepper } from './components.js';
|
||||
import { getCellTagEvents, getUpcomingEvents } from './events.js';
|
||||
import { getCellTagEvents, getUpcomingEvents, PRIORITY_WEATHER_IDS } from './events.js';
|
||||
import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS, FILTER_PROFILES, variantIcons, deriveProfileMain } from './config.js';
|
||||
import { useAppState } from './hooks/useAppState.js';
|
||||
import { DayTabs } from './components/DayTabs.js';
|
||||
@@ -223,9 +223,6 @@ export function UTCIForecast() {
|
||||
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
||||
liveElev,
|
||||
activeEvents, lensEvent, selectedDayEvents,
|
||||
bannerIndex, bannerTransition, bannerPrevIndex,
|
||||
bannerVisible, bannerStageRef,
|
||||
bannerSlideTo, dismissBanner,
|
||||
} = useAppState();
|
||||
|
||||
// Search is hidden by default and revealed via the magnifier next to the
|
||||
@@ -237,6 +234,39 @@ export function UTCIForecast() {
|
||||
const openSearch = () => { setSearchClosing(false); setSearchOpen(true); };
|
||||
const closeSearch = () => { setSearchOpen(false); setSearchClosing(true); };
|
||||
const searchWrapRef = useRef(null);
|
||||
|
||||
// ── Event note ───────────────────────────────────────────────────────
|
||||
// A content-width strip above the nav showing one active event at a time
|
||||
// with its full message, cycling through them with a fade. In flow, so it
|
||||
// pushes the page down rather than covering anything (see .event-note).
|
||||
const [noteIndex, setNoteIndex] = useState(0);
|
||||
const [noteFading, setNoteFading] = useState(false);
|
||||
const NOTE_MS = 8000;
|
||||
const NOTE_FADE_MS = 350;
|
||||
|
||||
const noteGoTo = (next) => {
|
||||
if (next === noteIndex) return;
|
||||
setNoteFading(true);
|
||||
setTimeout(() => { setNoteIndex(next); setNoteFading(false); }, NOTE_FADE_MS);
|
||||
};
|
||||
|
||||
// Keep the index in range if the event list shrinks between forecasts.
|
||||
useEffect(() => {
|
||||
if (noteIndex >= activeEvents.length) setNoteIndex(0);
|
||||
}, [activeEvents.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeEvents.length <= 1) return;
|
||||
const id = setInterval(() => {
|
||||
setNoteFading(true);
|
||||
setTimeout(() => {
|
||||
setNoteIndex(i => (i + 1) % activeEvents.length);
|
||||
setNoteFading(false);
|
||||
}, NOTE_FADE_MS);
|
||||
}, NOTE_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [activeEvents.length]);
|
||||
|
||||
const [colTogglesOpen, setColTogglesOpen] = useState(false);
|
||||
// Which column pills are offered in the Columns bar. The bar as a whole is
|
||||
// Extra-only (see the isPro gate around col-toggles-body), so this just asks
|
||||
@@ -645,7 +675,45 @@ export function UTCIForecast() {
|
||||
// • "utci-about" — the explainer paragraphs at the bottom
|
||||
// • "utci-footer" — the "reading the table" note
|
||||
return html`
|
||||
<div class="utci-app">
|
||||
<div class=${`utci-app${activeEvents.length > 0 ? ' has-event-note' : ''}`}>
|
||||
${activeEvents.length > 0 && (() => {
|
||||
const ev = activeEvents[noteIndex] || activeEvents[0];
|
||||
const isPriority = PRIORITY_WEATHER_IDS.has(ev.id);
|
||||
const fmtDate = (iso) => iso
|
||||
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
: null;
|
||||
const startFmt = fmtDate(ev.start);
|
||||
const peakFmt = fmtDate(ev.peak);
|
||||
const endFmt = fmtDate(ev.end);
|
||||
const dateLine = (startFmt && endFmt)
|
||||
? (startFmt === endFmt
|
||||
? (peakFmt ? `Peak ${peakFmt}` : startFmt)
|
||||
: (peakFmt && peakFmt !== startFmt && peakFmt !== endFmt
|
||||
? `${startFmt} – ${endFmt} · Peak ${peakFmt}`
|
||||
: `${startFmt} – ${endFmt}`))
|
||||
: null;
|
||||
return html`
|
||||
<div class=${`event-note${isPriority ? ' is-priority' : ''}`}>
|
||||
<span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
|
||||
<div class=${`event-note-slide${noteFading ? ' is-fading' : ''}`}>
|
||||
<span class="event-note-emoji">${ev.emoji}</span>
|
||||
<span class="event-note-title">${ev.title}</span>
|
||||
<span class="event-note-msg">${ev.message}</span>
|
||||
${dateLine && html`<span class="event-note-dates">${dateLine}</span>`}
|
||||
</div>
|
||||
${activeEvents.length > 1 && html`
|
||||
<div class="event-note-dots">
|
||||
${activeEvents.map((_, i) => html`
|
||||
<button
|
||||
key=${i}
|
||||
type="button"
|
||||
class=${`event-note-dot${i === noteIndex ? ' active' : ''}`}
|
||||
aria-label=${`Show event ${i + 1} of ${activeEvents.length}`}
|
||||
onClick=${() => noteGoTo(i)}
|
||||
/>`)}
|
||||
</div>`}
|
||||
</div>`;
|
||||
})()}
|
||||
<nav class="utci-topnav" id="site-nav">
|
||||
<div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div>
|
||||
<div class="nav-logo" aria-hidden="true">
|
||||
@@ -685,75 +753,6 @@ export function UTCIForecast() {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Event flyout — a direct child of .utci-app (sibling of the nav) so its
|
||||
position:fixed / z-index can sit above the nav. Keeping it inside
|
||||
.utci-shell would trap it in the shell's stacking context. -->
|
||||
<div class=${`event-banner-wrap${bannerVisible ? ' visible' : ''}`}>
|
||||
${activeEvents.length > 0 && (() => {
|
||||
const renderSlide = (idx, isOutgoing) => {
|
||||
const ev = activeEvents[idx] || activeEvents[0];
|
||||
const fmtDate = (iso) => iso
|
||||
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
: null;
|
||||
const startFmt = fmtDate(ev.start);
|
||||
const peakFmt = fmtDate(ev.peak);
|
||||
const endFmt = fmtDate(ev.end);
|
||||
const showDates = startFmt && endFmt;
|
||||
const dateLine = showDates
|
||||
? (startFmt === endFmt
|
||||
? peakFmt ? `Peak: ${peakFmt}` : `Date: ${startFmt}`
|
||||
: peakFmt && peakFmt !== startFmt && peakFmt !== endFmt
|
||||
? `Active ${startFmt} – ${endFmt} · Peak: ${peakFmt}`
|
||||
: `Active ${startFmt} – ${endFmt}`)
|
||||
: null;
|
||||
return html`
|
||||
<div class=${isOutgoing ? 'event-banner event-banner--outgoing' : 'event-banner'}
|
||||
style=${{ background: ev.color, color: ev.textColor }}
|
||||
onClick=${isOutgoing ? undefined : () => dismissBanner(ev.id)}
|
||||
role=${isOutgoing ? undefined : 'button'}
|
||||
title=${isOutgoing ? undefined : 'Click to dismiss'}
|
||||
>
|
||||
<span class="event-banner-emoji">${ev.emoji}</span>
|
||||
<div class="event-banner-body">
|
||||
<div class="event-banner-title">${ev.title}</div>
|
||||
<div class="event-banner-msg">${ev.message}</div>
|
||||
${dateLine && html`
|
||||
<div class="event-banner-dates" style=${{ opacity: 0.75, fontSize: '11px', fontFamily: 'Manrope, sans-serif', fontWeight: 700, letterSpacing: '0.05em', textTransform: 'uppercase', marginTop: '5px' }}>
|
||||
${dateLine}
|
||||
</div>
|
||||
`}
|
||||
${activeEvents.length > 1 && html`
|
||||
<div class="event-banner-dots" onClick=${(e) => e.stopPropagation()}>
|
||||
${activeEvents.map((_, i) => html`
|
||||
<span
|
||||
key=${i}
|
||||
class=${`event-banner-dot${i === bannerIndex ? ' active' : ''}`}
|
||||
onClick=${(e) => { e.stopPropagation(); bannerSlideTo(i); }}
|
||||
style=${{ background: ev.textColor }}
|
||||
/>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
${!isOutgoing && html`<button
|
||||
class="event-banner-dismiss"
|
||||
style=${{ color: ev.textColor }}
|
||||
onClick=${(e) => { e.stopPropagation(); dismissBanner(ev.id); }}
|
||||
aria-label="Dismiss"
|
||||
title="Dismiss this event"
|
||||
>✕</button>`}
|
||||
</div>`;
|
||||
};
|
||||
return html`
|
||||
<div class="event-banner-stage" ref=${bannerStageRef}>
|
||||
${bannerTransition === 'crossfading' && bannerPrevIndex !== null
|
||||
? renderSlide(bannerPrevIndex, true)
|
||||
: null}
|
||||
${renderSlide(bannerIndex, false)}
|
||||
</div>`;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<main class="utci-shell">
|
||||
<div class="utci-header">
|
||||
<div class="header-left">
|
||||
@@ -1106,7 +1105,7 @@ export function UTCIForecast() {
|
||||
<div key=${forecastView} class=${'col-toggles-wrap' + (colTogglesOpen ? ' col-toggles-wrap--open' : '')}>
|
||||
<div class=${'col-toggles' + (colTogglesOpen ? ' col-toggles--open' : '')}>
|
||||
<span class="fvt-interval">
|
||||
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} />
|
||||
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} label="Hours" />
|
||||
</span>
|
||||
<div class="col-toggles-body">
|
||||
${isPro && html`<${Fragment}>
|
||||
@@ -1290,7 +1289,7 @@ export function UTCIForecast() {
|
||||
${groupLabelRow()}
|
||||
<tr>
|
||||
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>
|
||||
<span>Hour</span>
|
||||
<span>Rows</span>
|
||||
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
||||
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
|
||||
</span>
|
||||
@@ -1507,8 +1506,8 @@ export function UTCIForecast() {
|
||||
${fmt(r.furSurfaceT)}${u('°C')}
|
||||
</td>`}
|
||||
${visibleCols.pawT && html`
|
||||
<td class=${groupStart('pawT')} title=${petCategory(r.concreteT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.concreteT, rPrev?.concreteT, rNext?.concreteT) }}>
|
||||
${fmt(r.concreteT)}${u('°C')}
|
||||
<td class=${groupStart('pawT')} title=${petCategory(r.pawT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.pawT, rPrev?.pawT, rNext?.pawT) }}>
|
||||
${fmt(r.pawT)}${u('°C')}
|
||||
</td>`}
|
||||
${visibleCols.soilT && html`
|
||||
<td class=${groupStart('soilT')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT0, rPrev?.soilT0, rNext?.soilT0) }}>${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}</td>`}
|
||||
@@ -1518,8 +1517,8 @@ export function UTCIForecast() {
|
||||
<td class=${groupStart('soilM')} style=${(() => { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}</td>`}
|
||||
${visibleCols.shadeT && html`<td class=${groupStart('shadeT')} style=${{ background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT), color: airTempFontColor() }}>${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}</td>`}
|
||||
${visibleCols.petShadeT && html`
|
||||
<td class=${groupStart('petShadeT')} title=${r.shadeT != null ? (petCategory(r.shadeT)?.label ?? '') : ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT) }}>
|
||||
${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}
|
||||
<td class=${groupStart('petShadeT')} title=${r.petShadeT != null ? (petCategory(r.petShadeT)?.label ?? '') : ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.petShadeT, rPrev?.petShadeT, rNext?.petShadeT) }}>
|
||||
${r.petShadeT != null ? fmt(r.petShadeT) + u('°C') : '—'}
|
||||
</td>`}
|
||||
${visibleCols.air && html`<td class=${groupStart('air')} style=${{ background: airTempBg(r.Ta, rPrev?.Ta, rNext?.Ta), color: airTempFontColor() }}>${fmt(r.Ta)}${u('°C')}</td>`}
|
||||
${visibleCols.rh && html`<td class=${groupStart('rh')} style=${{ background: scaleBg(r.RH, 30, 100, '70,145,200') }}>${Math.round(r.RH)}${u('%')}</td>`}
|
||||
|
||||
@@ -7,7 +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
|
||||
// RowStepper({ value, onChange, showLabel, label }) 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
|
||||
@@ -271,16 +271,18 @@ export function VentPill({ checked, onChange, label, title, grpClass }) {
|
||||
// props:
|
||||
// value - 1..4
|
||||
// onChange - fn(n)
|
||||
// showLabel - draw the "Rows" caption (off inside the Hour header,
|
||||
// showLabel - draw the caption (off inside the table's Rows header,
|
||||
// where the column title already supplies the context)
|
||||
// label - caption text: "Rows" by default, "Hours" in quick view,
|
||||
// where each card is an hour rather than a table row
|
||||
// min/max - bounds, so 6h/12h can be added without a redesign
|
||||
// -------------------------------------------------------------------
|
||||
export function RowStepper({ value, onChange, showLabel = true, min = 1, max = 4 }) {
|
||||
export function RowStepper({ value, onChange, showLabel = true, label = 'Rows', 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>`}
|
||||
${showLabel && html`<span class="row-stepper-label">${label}</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>
|
||||
|
||||
+29
-12
@@ -154,6 +154,10 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
||||
uv, uvA, uvB,
|
||||
precip, precipProb, lightning, cape, snow,
|
||||
soilT0, soilT6, soilM, shadeT,
|
||||
// Pet Shade reads the same hourly value as Shade but aggregates as a
|
||||
// MEAN rather than a worst case, so it needs its own field to diverge
|
||||
// from shadeT at 2h/3h/4h. Same for pawT vs concreteT below.
|
||||
petShadeT: shadeT,
|
||||
effectiveRad,
|
||||
elev, Tmrt, utci, utciAdj, eh, compass,
|
||||
visKm, aqi,
|
||||
@@ -182,7 +186,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
||||
const concreteTemps = calcConcreteTempPass(
|
||||
TaArr, radArr, vaArr, elevArr, uvArr, cloudCatArr, soilMArr, precipArr, snowArr
|
||||
);
|
||||
hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; });
|
||||
hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; r.pawT = concreteTemps[i]; });
|
||||
|
||||
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType);
|
||||
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType);
|
||||
@@ -267,10 +271,15 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
||||
//
|
||||
// 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)
|
||||
// - MAX : risk/peak columns (rain %, UV, gusts, managed indoor)
|
||||
// - MEAN : smooth continuous quantities (RH, wind, cloud, Tmrt, ...)
|
||||
// - FELT : worst-case temperature - the warmest hour if the block reaches
|
||||
// >=20C (heat is the concern), otherwise the coldest hour (cold
|
||||
// is the concern). Applies to EVERY temperature column the user
|
||||
// judges comfort or risk by - SunSoak, Cabin, Indoors, Concrete,
|
||||
// Soil, Shade and Air - so they never disagree with each other
|
||||
// at 2h/3h/4h. Note Air is therefore a block worst case, not a
|
||||
// block average, and will read hotter than a mean on a warm day.
|
||||
//
|
||||
// Derived render values (Delta, Burn) are recomputed downstream from the
|
||||
// aggregated Air / UTCI / UV, so they stay consistent automatically.
|
||||
@@ -278,16 +287,24 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
|
||||
// interval <= 1 returns the input untouched (zero behaviour change).
|
||||
// ------------------------------------------------------------------------
|
||||
const AGG_MEAN = [
|
||||
'Ta', 'shadeT', 'RH', 'dew', 'va', 'dir', 'dif', 'cc', 'ccLow', 'ccMid', 'ccHigh',
|
||||
'soilT0', 'soilT6', 'soilM', 'Tmrt', 'eh', 'visKm', 'aqi', 'effectiveRad',
|
||||
'RH', 'dew', 'va', 'dir', 'dif', 'cc', 'ccLow', 'ccMid', 'ccHigh',
|
||||
'soilM', 'Tmrt', 'eh', 'visKm', 'aqi', 'effectiveRad',
|
||||
// Thermal columns that read as a plain block average rather than a worst
|
||||
// case: Tmrt and Dew above, plus the pet trio. pawT/petShadeT mirror
|
||||
// concreteT/shadeT hourly but must NOT inherit their FELT aggregation.
|
||||
'furSurfaceT', 'pawT', 'petShadeT',
|
||||
'grassPollen', 'birchPollen', 'alderPollen', 'mugwortPollen', 'olivePollen', 'ragweedPollen',
|
||||
];
|
||||
const AGG_MAX = [
|
||||
'precipProb', 'uv', 'uvA', 'uvB', 'gust',
|
||||
'vehicleT', 'concreteT', 'indoorT', 'managedT',
|
||||
];
|
||||
const AGG_MAX = ['precipProb', 'uv', 'uvA', 'uvB', 'gust'];
|
||||
const AGG_SUM = ['precip', 'snow'];
|
||||
const AGG_FELT = ['utci', 'utciAdj'];
|
||||
// Every temperature the user reads as "how hot/cold does this actually get"
|
||||
// shares one rule, so a 3h row never disagrees with the columns beside it.
|
||||
const AGG_FELT = [
|
||||
'utci', 'utciAdj', // SunSoak
|
||||
'vehicleT', 'concreteT', // Cabin, Concrete
|
||||
'indoorT', 'managedT', // Indoors + Managed Indoors (+ Pet Home)
|
||||
'soilT0', 'soilT6', 'shadeT', 'Ta', // Soil surface + root, Shade, Air
|
||||
];
|
||||
|
||||
export function aggregateRows(rows, interval) {
|
||||
if (!rows || rows.length === 0 || !interval || interval <= 1) return rows;
|
||||
|
||||
+10
-3
@@ -29,7 +29,10 @@ export const FILTER_PROFILES = {
|
||||
label: 'Basic',
|
||||
icon: '🌡️',
|
||||
scene: "url('assets/images/profiles/basic.png') center / cover no-repeat, linear-gradient(160deg,#b8d8e8,#daeef8)",
|
||||
cols: { hour: true, air: true, shadeT: true, rh: true, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||
// Basic is the "just tell me" preset - it opens in Quick view (see `view`
|
||||
// below) with the smallest useful column set behind it.
|
||||
view: 'simple',
|
||||
cols: { hour: true, air: false, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: false, lightning: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||
},
|
||||
home: {
|
||||
label: 'Home',
|
||||
@@ -53,12 +56,16 @@ export const FILTER_PROFILES = {
|
||||
label: 'Farming',
|
||||
icon: '🌾',
|
||||
scene: "url('assets/images/profiles/farming.png') center / cover no-repeat, linear-gradient(160deg,#d8bc90,#ece0b8)",
|
||||
// Farming sits in the Work menu alongside the outdoors variants, so it
|
||||
// opens the detailed table the same way they do.
|
||||
view: 'table',
|
||||
cols: { hour: true, air: true, shadeT: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||
},
|
||||
outdoors: {
|
||||
label: 'Places',
|
||||
icon: '🌤️',
|
||||
scene: "url('assets/images/profiles/places.png') center / cover no-repeat, linear-gradient(160deg,#98ccc0,#c4e4dc)",
|
||||
view: 'table',
|
||||
// Default cols match the first variant (urban). Switching variant updates visibleCols.
|
||||
cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||
},
|
||||
@@ -251,9 +258,9 @@ 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 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.' },
|
||||
hour: { title: 'Rows', desc: 'Local wall-clock time for this forecast row. Each row covers the interval set by the stepper in this header, 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.' },
|
||||
shadeT: { title: 'Shade', 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.' },
|
||||
dew: { title: 'Dew Point', desc: 'The temperature at which air becomes saturated and moisture begins to condense. A useful measure of absolute humidity — above 16 °C it starts to feel muggy; above 21 °C it feels oppressive.' },
|
||||
wind: { title: 'Wind Speed', desc: 'Mean wind speed at 10 m in mph, with peak gust in brackets where significantly higher. Gust colour indicates Beaufort scale severity: amber = Near Gale (32+ mph), orange = Gale (39+ mph), red = Severe Gale or above (55+ mph). Banners above the table classify conditions from Strong Breeze through to Violent Storm using standard wind categories. Wind dramatically increases heat loss from exposed skin — the basis of wind chill.' },
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ export function getCellTagEvents(events, row) {
|
||||
// --- BANNER PRIORITY -----------------------------------------------------
|
||||
// Weather event IDs that are safety-relevant and should always appear as the
|
||||
// first banner slide, ahead of cosmic events and lower-priority weather items.
|
||||
const PRIORITY_WEATHER_IDS = new Set(['heat-spike', 'frost', 'storm', 'wind', 'wet']);
|
||||
export const PRIORITY_WEATHER_IDS = new Set(['heat-spike', 'frost', 'storm', 'wind', 'wet']);
|
||||
|
||||
// --- MAIN EXPORT ---------------------------------------------------------
|
||||
// Returns ALL active events for the given rows/date as an array.
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
// 7. Column popup + table scroll hooks
|
||||
// 8. Geocoding search effect
|
||||
// 9. Computation - rows, days, current row
|
||||
// 10. Banner - events, snooze, slideshow
|
||||
// 10. Events - active, lens, selected-day
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js';
|
||||
@@ -336,6 +336,8 @@ export function useAppState() {
|
||||
const profile = FILTER_PROFILES[key];
|
||||
try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
|
||||
setActiveProfile(key);
|
||||
// Profiles can declare a preferred forecast view (Basic opens in Quick).
|
||||
if (profile?.view) setForecastView(profile.view);
|
||||
if (key !== 'custom') {
|
||||
setVisibleCols({ ...profile.cols });
|
||||
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
|
||||
@@ -387,6 +389,10 @@ export function useAppState() {
|
||||
const setOutdoorsVariantAndSave = (v) => {
|
||||
try { localStorage.setItem('sunscope_outdoors_variant', v); } catch (e) { /* ignore */ }
|
||||
setOutdoorsVariant(v);
|
||||
// Every Places / Activities / Work variant carries a wide, specific column
|
||||
// set - Quick view can only show a handful of those, so open the detailed
|
||||
// table. (Basic goes the other way; see FILTER_PROFILES.basic.view.)
|
||||
setForecastView('table');
|
||||
// Sync the column toggle buttons to the variants own cols so the UI matches
|
||||
// the autoloaded selection - eg Beach selects Dew. Sun. UV-B and Running selects RH. Pollen. UTCI.
|
||||
const variantCols = OUTDOORS_VARIANTS[v]?.cols;
|
||||
@@ -675,99 +681,15 @@ export function useAppState() {
|
||||
? utciCategory(currentRow.utciAdj)
|
||||
: { bg: '#4a4228', fg: '#ede4cc', label: 'No data' };
|
||||
|
||||
// ── 10. BANNER + EVENTS ───────────────────────────────────────────────
|
||||
const [dismissedEventIds, setDismissedEventIds] = useState([]);
|
||||
|
||||
const BANNER_SNOOZE_HOURS = 6;
|
||||
const BANNER_SNOOZE_MS = BANNER_SNOOZE_HOURS * 60 * 60 * 1000;
|
||||
const BANNER_SNOOZE_KEY = id => `sunscope.banner.snoozed.${id}`;
|
||||
|
||||
const [bannerIndex, setBannerIndex] = useState(0);
|
||||
const [bannerTransition, setBannerTransition] = useState(null);
|
||||
const [bannerPrevIndex, setBannerPrevIndex] = useState(null);
|
||||
const [bannerVisible, setBannerVisible] = useState(false);
|
||||
const bannerIndexRef = useRef(0);
|
||||
const bannerStageRef = useRef(null);
|
||||
|
||||
useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]);
|
||||
|
||||
const isSnoozed = id => {
|
||||
try {
|
||||
const ts = localStorage.getItem(BANNER_SNOOZE_KEY(id));
|
||||
return ts && (Date.now() - Number(ts)) < BANNER_SNOOZE_MS;
|
||||
} catch (e) { return false; }
|
||||
};
|
||||
|
||||
// ── 10. EVENTS ────────────────────────────────────────────────────────
|
||||
// Events surface as the .event-note strip above the nav (see app.js). It
|
||||
// is in flow rather than an overlay, so there is no dismiss or snooze
|
||||
// state to track.
|
||||
const todayRows = days[0]?.rows || [];
|
||||
const activeEvents = getActiveEvents(todayRows, location)
|
||||
.filter(ev => !dismissedEventIds.includes(ev.id) && !isSnoozed(ev.id));
|
||||
const activeEvents = getActiveEvents(todayRows, location);
|
||||
const lensEvent = getLensEvent(activeEvents);
|
||||
const selectedDayEvents = getActiveEvents(visible, location);
|
||||
|
||||
// Only show banner when events first appear - not on every re-render
|
||||
// Using a ref to track previous length avoids re-showing during dismiss animation
|
||||
const prevActiveEventsLenRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const prev = prevActiveEventsLenRef.current;
|
||||
prevActiveEventsLenRef.current = activeEvents.length;
|
||||
if (activeEvents.length > 0 && prev === 0) setBannerVisible(true);
|
||||
}, [activeEvents.length]);
|
||||
|
||||
const bannerSlideTo = useCallback((next) => {
|
||||
const stage = bannerStageRef.current;
|
||||
if (stage) {
|
||||
stage.style.height = stage.offsetHeight + 'px';
|
||||
stage.style.transition = 'height 0.45s cubic-bezier(0.4,0,0.2,1)';
|
||||
}
|
||||
setBannerPrevIndex(bannerIndexRef.current);
|
||||
setBannerIndex(next);
|
||||
setBannerTransition('crossfading');
|
||||
setTimeout(() => {
|
||||
if (stage) {
|
||||
const incoming = stage.querySelector('.event-banner:not(.event-banner--outgoing)');
|
||||
if (incoming) stage.style.height = incoming.offsetHeight + 'px';
|
||||
}
|
||||
}, 16);
|
||||
setTimeout(() => {
|
||||
setBannerTransition(null);
|
||||
setBannerPrevIndex(null);
|
||||
if (stage) { stage.style.height = ''; stage.style.transition = ''; }
|
||||
}, 460);
|
||||
}, []);
|
||||
|
||||
const dismissBanner = useCallback((evId) => {
|
||||
// Do NOT write the snooze to localStorage yet. activeEvents filters out snoozed events
|
||||
// synchronously on the very next render. so writing here would cause the banner to be
|
||||
// unmounted before the fade animation can start - which is what made it snap closed.
|
||||
// We snooze AFTER the animation finishes inside the setTimeout below.
|
||||
requestAnimationFrame(() => {
|
||||
setBannerVisible(false);
|
||||
setTimeout(() => {
|
||||
try { localStorage.setItem(BANNER_SNOOZE_KEY(evId), String(Date.now())); } catch (e) {}
|
||||
setDismissedEventIds(ids => [...ids, evId]);
|
||||
setBannerIndex(0);
|
||||
}, 500);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeEvents.length <= 1) { setBannerIndex(0); return; }
|
||||
const id = setInterval(() => {
|
||||
const next = (bannerIndexRef.current + 1) % activeEvents.length;
|
||||
bannerSlideTo(next);
|
||||
}, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, [activeEvents.length, activeEvents.map(e => e.id).join(','), bannerSlideTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bannerIndex >= activeEvents.length) setBannerIndex(0);
|
||||
}, [activeEvents.length]);
|
||||
|
||||
// The banner is now a position:fixed flyout pinned to the very top of the
|
||||
// screen (see .event-banner-wrap in ui.css). It floats over all content and
|
||||
// slides in/out via a transform, so no JS nav-pinning or shell padding is
|
||||
// needed.
|
||||
|
||||
// ── RETURN ALL STATE + HANDLERS ───────────────────────────────────────
|
||||
return {
|
||||
// location
|
||||
@@ -831,10 +753,7 @@ export function useAppState() {
|
||||
hourlyRows, days, utcOffsetMs,
|
||||
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
||||
liveElev,
|
||||
// banner + events
|
||||
// events
|
||||
activeEvents, lensEvent, selectedDayEvents,
|
||||
bannerIndex, bannerTransition, bannerPrevIndex,
|
||||
bannerVisible, bannerStageRef,
|
||||
bannerSlideTo, dismissBanner,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user