Restored Banner, MPH Gusts and header & footer

This commit is contained in:
fraxle
2026-05-20 15:15:07 +01:00
parent 476b4dfc75
commit 2b78384dd0
2 changed files with 82 additions and 39 deletions
+12 -7
View File
@@ -42,6 +42,7 @@
margin-bottom: 12px;
}
/* True crossfade — outgoing slide sits absolutely on top and fades out */
.event-banner {
display: flex;
align-items: center;
@@ -49,17 +50,21 @@
padding: 13px 18px;
border-radius: 4px;
position: relative;
z-index: 1;
border-left: 4px solid rgba(255,255,255,0.25);
}
/* Single-element crossfade — content swaps during the transition gap */
.event-banner.banner-exiting {
animation: banner-fade-out 0.4s ease both;
.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.banner-entering {
animation: banner-fade-in 0.4s ease both;
.event-banner-stage {
position: relative;
overflow: hidden;
}
.event-banner-emoji {
font-size: 24px;
+70 -32
View File
@@ -480,10 +480,12 @@ export function UTCIForecast() {
const BANNER_SNOOZE_KEY = id => `sunscope.banner.snoozed.${id}`;
const [bannerIndex, setBannerIndex] = useState(0);
const [bannerTransition, setBannerTransition] = useState(null); // 'entering' | 'exiting' | null
const [bannerPrevIndex, setBannerPrevIndex] = useState(null);
// bannerVisible drives the .visible class — kept true until the collapse
// animation finishes so content doesn't vanish before the wrap closes.
const [bannerVisible, setBannerVisible] = useState(false);
const bannerIndexRef = useRef(0);
const bannerStageRef = useRef(null);
useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]);
const isSnoozed = id => {
@@ -504,14 +506,31 @@ export function UTCIForecast() {
if (activeEvents.length > 0) setBannerVisible(true);
}, [activeEvents.length]);
// Crossfade to a new banner index: fade out → swap → fade in
// True crossfade: render prev + next simultaneously, prev fades out on top
// Height is locked to current value then released after transition so it
// animates smoothly between slides of different heights.
const bannerSlideTo = useCallback((next) => {
setBannerTransition('exiting');
const stage = bannerStageRef.current;
if (stage) {
// Lock current height so CSS transition has a start point
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');
// After Preact re-renders with new slide, measure new height and animate to it
setTimeout(() => {
setBannerIndex(next);
setBannerTransition('entering');
setTimeout(() => setBannerTransition(null), 420);
}, 400);
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);
}, []);
// Dismiss with animation: fade stage → collapse wrap → remove event
@@ -563,6 +582,7 @@ export function UTCIForecast() {
<div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a>
<a href="./about.html">About</a>
<a href="./faq.html">FAQ</a>
<a href=${isPro
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
@@ -585,25 +605,24 @@ export function UTCIForecast() {
<div class=${`event-banner-wrap${bannerVisible ? ' visible' : ''}`}>
${activeEvents.length > 0 && (() => {
const ev = activeEvents[bannerIndex] || 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);
// Only show date line if the event has start/end (cosmic events do; weather events don't)
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="event-banner-stage">
<div class=${`event-banner${bannerTransition ? ` banner-${bannerTransition}` : ''}`}
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 }}
>
<span class="event-banner-emoji">${ev.emoji}</span>
@@ -628,14 +647,21 @@ export function UTCIForecast() {
</div>
`}
</div>
<button
${!isOutgoing && html`<button
class="event-banner-dismiss"
style=${{ color: ev.textColor }}
onClick=${() => dismissBanner(ev.id)}
aria-label="Dismiss"
title="Dismiss this event"
></button>
</div>
>✕</button>`}
</div>`;
};
return html`
<div class="event-banner-stage" ref=${bannerStageRef}>
${bannerTransition === 'crossfading' && bannerPrevIndex !== null
? renderSlide(bannerPrevIndex, true)
: null}
${renderSlide(bannerIndex, false)}
</div>`;
})()}
</div>
@@ -1265,7 +1291,7 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
${visibleCols.soilT6 && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilT6', e)} onMouseEnter=${(e) => handleThEnter('soilT6', e)} onMouseLeave=${handleThLeave}>Soil 6cm <span class="col-unit">°C root</span></th>`}
${visibleCols.soilM && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist <span class="col-unit">m³/m³</span></th>`}
${visibleCols.concreteT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete <span class="col-unit">°C surface</span></th>`}
${visibleCols.wind && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">m/s (gust)</span></th>`}
${visibleCols.wind && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">mph (gust)</span></th>`}
${visibleCols.dir && html`<th class="utci-dir-cell col-info-th" scope="col" onClick=${(e) => handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir <span class="col-unit">-</span></th>`}
${visibleCols.cloud && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud <span class="col-unit">%</span></th>`}
${visibleCols.vis && html`<th class="utci-tight-head col-info-th" scope="col" onClick=${(e) => handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Vis <span class="col-unit">km</span></th>`}
@@ -1390,9 +1416,9 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
<td style=${{ color: r.concreteT != null && r.concreteT > 40 ? '#c0392b' : r.concreteT != null && r.concreteT > 30 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.concreteT) }}>
${r.concreteT != null ? r.concreteT.toFixed(1) : '—'}
</td>`}
${visibleCols.wind && html`<td style=${{ background: scaleBg(r.gust ?? r.va, 0, 18, '85,130,180') }}>
${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5
? html`<span style=${{ opacity: 0.65, marginLeft: '4px' }}>(${r.gust.toFixed(1)})</span>`
${visibleCols.wind && html`<td style=${{ background: scaleBg((r.gust ?? r.va) * 2.237, 0, 40, '85,130,180') }}>
${Math.round(r.va * 2.237)}${r.gust != null && r.gust > r.va + 0.5
? (gm => html`<span style=${{ marginLeft: '4px', fontWeight: gm >= 25 ? 700 : 'normal', opacity: gm >= 25 ? 1 : 0.65, color: gm >= 55 ? '#b81010' : gm >= 40 ? '#d44010' : gm >= 25 ? '#c47a00' : 'inherit' }}>(${Math.round(gm)})</span>`)(r.gust * 2.237)
: ''}
</td>`}
${visibleCols.dir && html`<td class="utci-dir-cell">
@@ -1683,6 +1709,18 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
`}
</div>
<footer class="utci-site-footer">
&copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a>
· <a href="./index.html">Forecast</a>
· <a href="./about.html">About</a>
· <a href="./faq.html">FAQ</a>
· <a href=${isPro
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
target="_blank" rel="noopener noreferrer">Account</a>
· Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a>
· UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
</footer>
</main>
</div>`;
}