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:
fraxle
2026-08-01 16:18:15 +01:00
parent 2869694634
commit e55e0df86f
15 changed files with 406 additions and 380 deletions
+79 -80
View File
@@ -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>`}