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:
+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>`}
|
||||
|
||||
Reference in New Issue
Block a user