Restored Banner, MPH Gusts and header & footer
This commit is contained in:
+12
-7
@@ -42,6 +42,7 @@
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* True crossfade — outgoing slide sits absolutely on top and fades out */
|
||||||
.event-banner {
|
.event-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -49,17 +50,21 @@
|
|||||||
padding: 13px 18px;
|
padding: 13px 18px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
border-left: 4px solid rgba(255,255,255,0.25);
|
border-left: 4px solid rgba(255,255,255,0.25);
|
||||||
}
|
}
|
||||||
|
.event-banner--outgoing {
|
||||||
/* Single-element crossfade — content swaps during the transition gap */
|
position: absolute;
|
||||||
.event-banner.banner-exiting {
|
top: 0;
|
||||||
animation: banner-fade-out 0.4s ease both;
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 2;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
animation: banner-fade-out 0.45s ease both;
|
||||||
}
|
}
|
||||||
|
.event-banner-stage {
|
||||||
.event-banner.banner-entering {
|
position: relative;
|
||||||
animation: banner-fade-in 0.4s ease both;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.event-banner-emoji {
|
.event-banner-emoji {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
|||||||
+55
-17
@@ -480,10 +480,12 @@ export function UTCIForecast() {
|
|||||||
const BANNER_SNOOZE_KEY = id => `sunscope.banner.snoozed.${id}`;
|
const BANNER_SNOOZE_KEY = id => `sunscope.banner.snoozed.${id}`;
|
||||||
const [bannerIndex, setBannerIndex] = useState(0);
|
const [bannerIndex, setBannerIndex] = useState(0);
|
||||||
const [bannerTransition, setBannerTransition] = useState(null); // 'entering' | 'exiting' | null
|
const [bannerTransition, setBannerTransition] = useState(null); // 'entering' | 'exiting' | null
|
||||||
|
const [bannerPrevIndex, setBannerPrevIndex] = useState(null);
|
||||||
// bannerVisible drives the .visible class — kept true until the collapse
|
// bannerVisible drives the .visible class — kept true until the collapse
|
||||||
// animation finishes so content doesn't vanish before the wrap closes.
|
// animation finishes so content doesn't vanish before the wrap closes.
|
||||||
const [bannerVisible, setBannerVisible] = useState(false);
|
const [bannerVisible, setBannerVisible] = useState(false);
|
||||||
const bannerIndexRef = useRef(0);
|
const bannerIndexRef = useRef(0);
|
||||||
|
const bannerStageRef = useRef(null);
|
||||||
useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]);
|
useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]);
|
||||||
|
|
||||||
const isSnoozed = id => {
|
const isSnoozed = id => {
|
||||||
@@ -504,14 +506,31 @@ export function UTCIForecast() {
|
|||||||
if (activeEvents.length > 0) setBannerVisible(true);
|
if (activeEvents.length > 0) setBannerVisible(true);
|
||||||
}, [activeEvents.length]);
|
}, [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) => {
|
const bannerSlideTo = useCallback((next) => {
|
||||||
setBannerTransition('exiting');
|
const stage = bannerStageRef.current;
|
||||||
setTimeout(() => {
|
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);
|
setBannerIndex(next);
|
||||||
setBannerTransition('entering');
|
setBannerTransition('crossfading');
|
||||||
setTimeout(() => setBannerTransition(null), 420);
|
// After Preact re-renders with new slide, measure new height and animate to it
|
||||||
}, 400);
|
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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Dismiss with animation: fade stage → collapse wrap → remove event
|
// Dismiss with animation: fade stage → collapse wrap → remove event
|
||||||
@@ -563,6 +582,7 @@ export function UTCIForecast() {
|
|||||||
<div class="nav-links" id="nav-drawer">
|
<div class="nav-links" id="nav-drawer">
|
||||||
<a href="./index.html">Forecast</a>
|
<a href="./index.html">Forecast</a>
|
||||||
<a href="./about.html">About</a>
|
<a href="./about.html">About</a>
|
||||||
|
<a href="./faq.html">FAQ</a>
|
||||||
<a href=${isPro
|
<a href=${isPro
|
||||||
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
|
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
|
||||||
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
|
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
|
||||||
@@ -585,14 +605,14 @@ export function UTCIForecast() {
|
|||||||
|
|
||||||
<div class=${`event-banner-wrap${bannerVisible ? ' visible' : ''}`}>
|
<div class=${`event-banner-wrap${bannerVisible ? ' visible' : ''}`}>
|
||||||
${activeEvents.length > 0 && (() => {
|
${activeEvents.length > 0 && (() => {
|
||||||
const ev = activeEvents[bannerIndex] || activeEvents[0];
|
const renderSlide = (idx, isOutgoing) => {
|
||||||
|
const ev = activeEvents[idx] || activeEvents[0];
|
||||||
const fmtDate = (iso) => iso
|
const fmtDate = (iso) => iso
|
||||||
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
: null;
|
: null;
|
||||||
const startFmt = fmtDate(ev.start);
|
const startFmt = fmtDate(ev.start);
|
||||||
const peakFmt = fmtDate(ev.peak);
|
const peakFmt = fmtDate(ev.peak);
|
||||||
const endFmt = fmtDate(ev.end);
|
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 showDates = startFmt && endFmt;
|
||||||
const dateLine = showDates
|
const dateLine = showDates
|
||||||
? (startFmt === endFmt
|
? (startFmt === endFmt
|
||||||
@@ -602,8 +622,7 @@ export function UTCIForecast() {
|
|||||||
: `Active ${startFmt} – ${endFmt}`)
|
: `Active ${startFmt} – ${endFmt}`)
|
||||||
: null;
|
: null;
|
||||||
return html`
|
return html`
|
||||||
<div class="event-banner-stage">
|
<div class=${isOutgoing ? 'event-banner event-banner--outgoing' : 'event-banner'}
|
||||||
<div class=${`event-banner${bannerTransition ? ` banner-${bannerTransition}` : ''}`}
|
|
||||||
style=${{ background: ev.color, color: ev.textColor }}
|
style=${{ background: ev.color, color: ev.textColor }}
|
||||||
>
|
>
|
||||||
<span class="event-banner-emoji">${ev.emoji}</span>
|
<span class="event-banner-emoji">${ev.emoji}</span>
|
||||||
@@ -628,14 +647,21 @@ export function UTCIForecast() {
|
|||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
<button
|
${!isOutgoing && html`<button
|
||||||
class="event-banner-dismiss"
|
class="event-banner-dismiss"
|
||||||
style=${{ color: ev.textColor }}
|
style=${{ color: ev.textColor }}
|
||||||
onClick=${() => dismissBanner(ev.id)}
|
onClick=${() => dismissBanner(ev.id)}
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
title="Dismiss this event"
|
title="Dismiss this event"
|
||||||
>✕</button>
|
>✕</button>`}
|
||||||
</div>
|
</div>`;
|
||||||
|
};
|
||||||
|
return html`
|
||||||
|
<div class="event-banner-stage" ref=${bannerStageRef}>
|
||||||
|
${bannerTransition === 'crossfading' && bannerPrevIndex !== null
|
||||||
|
? renderSlide(bannerPrevIndex, true)
|
||||||
|
: null}
|
||||||
|
${renderSlide(bannerIndex, false)}
|
||||||
</div>`;
|
</div>`;
|
||||||
})()}
|
})()}
|
||||||
</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.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.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.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.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.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>`}
|
${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) }}>
|
<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) : '—'}
|
${r.concreteT != null ? r.concreteT.toFixed(1) : '—'}
|
||||||
</td>`}
|
</td>`}
|
||||||
${visibleCols.wind && html`<td style=${{ background: scaleBg(r.gust ?? r.va, 0, 18, '85,130,180') }}>
|
${visibleCols.wind && html`<td style=${{ background: scaleBg((r.gust ?? r.va) * 2.237, 0, 40, '85,130,180') }}>
|
||||||
${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5
|
${Math.round(r.va * 2.237)}${r.gust != null && r.gust > r.va + 0.5
|
||||||
? html`<span style=${{ opacity: 0.65, marginLeft: '4px' }}>(${r.gust.toFixed(1)})</span>`
|
? (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>`}
|
</td>`}
|
||||||
${visibleCols.dir && html`<td class="utci-dir-cell">
|
${visibleCols.dir && html`<td class="utci-dir-cell">
|
||||||
@@ -1683,6 +1709,18 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
|
|||||||
`}
|
`}
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user