Restored Banner, MPH Gusts and header & footer
This commit is contained in:
+70
-32
@@ -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">
|
||||
© 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>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user