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
View File
+153 -1
View File
@@ -94,7 +94,7 @@ body {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 18px 32px;
padding: 0px 32px;
gap: 28px;
}
@@ -188,6 +188,158 @@ body {
border-bottom: none;
}
/* ── Event note ─────────────────────────────────────────────────────── */
/* Active cosmic/weather events shown as a strip across the content width,
above the nav. It is IN FLOW — it pushes the page down instead of
covering anything, which is the whole point of it not being a flyout.
One event at a time with its full message, cycling with a fade.
The nav is normally position:absolute and .utci-app reserves 52px of top
padding for it. When a note is present the nav joins the flow instead so
the note can sit above it, and that padding is no longer needed. On true
mobile (≤640px) the nav is position:fixed and stays that way — there the
note simply becomes the first thing under it, so no override applies. */
.event-note {
display: flex;
align-items: baseline;
gap: 11px;
padding: 10px 15px;
margin-bottom: 14px;
background: #fdf8ee;
border: 1.5px solid #c9b08a;
border-radius: 4px;
position: relative;
overflow: hidden;
animation: event-note-in 0.35s cubic-bezier(0.34, 1.25, 0.64, 1) both;
}
@keyframes event-note-in {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
/* Per-event colour, kept to a hairline so the strip stays parchment —
same device as .almanac-entry-accent. */
.event-note-accent {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
opacity: 0.75;
}
/* Safety-relevant weather (heat, frost, storm, wind, wet) — brass emphasis */
.event-note.is-priority {
background: #f0e4c4;
border-color: #c8922a;
}
.event-note-slide {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 10px;
flex: 1;
min-width: 0;
opacity: 1;
transition: opacity 0.35s ease;
}
.event-note-slide.is-fading {
opacity: 0;
}
.event-note-emoji {
font-size: 16px;
line-height: 1;
flex-shrink: 0;
}
.event-note-title {
font-family: Fraunces, serif;
font-style: italic;
font-weight: 700;
font-size: 14px;
color: #2a1a08;
flex-shrink: 0;
}
.event-note-msg {
font-family: Manrope, sans-serif;
font-size: 12.5px;
font-weight: 500;
line-height: 1.45;
color: #6b4f2a;
min-width: 0;
}
.event-note-dates {
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9a7d5a;
white-space: nowrap;
margin-left: auto; /* push the dates to the right-hand end */
flex-shrink: 0;
}
.event-note.is-priority .event-note-dates {
color: #a06e18;
}
.event-note-dots {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
align-self: center;
}
.event-note-dot {
width: 6px;
height: 6px;
padding: 0;
border: none;
border-radius: 50%;
background: #d9c39b;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
}
.event-note-dot:hover {
background: #c0a880;
}
.event-note-dot.active {
background: #c8922a;
transform: scale(1.3);
}
/* Above true-mobile: the nav drops into the flow beneath the note. Its own
32px side padding is dropped so the links line up with the note and the
content rather than sitting 32px inside them. */
@media (min-width: 641px) {
.utci-app.has-event-note {
padding-top: 18px;
}
/* `relative`, not `static` — the nav keeps its z-index:100 that way, so
the About dropdown still paints over .utci-shell (which is z-index:1). */
.utci-app.has-event-note .utci-topnav {
position: relative;
padding-left: 0;
padding-right: 0;
}
}
@media (max-width: 640px) {
.event-note {
align-items: flex-start;
gap: 9px;
padding: 10px 12px;
}
.event-note-dates {
margin-left: 0; /* no room to right-align on a phone */
}
}
/* Nav logo — hidden on desktop, shown on mobile */
.nav-logo {
display: none;
-170
View File
@@ -2,136 +2,6 @@
ui.css — Status banners, legend, about block, footer, and responsive.
════════════════════════════════════════════════════════════════════════ */
/* ── EVENT BANNER ───────────────────────────────────────────────────── */
/* Animated slide-down banner for cosmic/weather/promo events. */
/* Flyout: a fixed panel pinned to the very top of the screen that slides
down over everything (including the nav) and retracts upward on close. */
.event-banner-wrap {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
display: flex;
justify-content: center;
padding: 0 14px;
transform: translateY(-130%);
transition: transform 0.5s cubic-bezier(0.34, 1.25, 0.64, 1);
/* The wrap is full-width but the panel is centred — keep the wrap itself
click-through so its transparent sides never block the nav beneath it.
Only the visible banner panel (.event-banner) captures clicks. */
pointer-events: none;
}
.event-banner-wrap.visible {
transform: translateY(0);
}
/* Slide crossfade: both banners rendered simultaneously, fading into each other */
@keyframes banner-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes banner-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
/* Container that holds both slides during a crossfade. Constrains the
flyout to a centred panel width and clips the absolute outgoing slide. */
.event-banner-stage {
position: relative;
overflow: hidden;
width: 100%;
max-width: 720px;
}
/* True crossfade — outgoing slide sits absolutely on top and fades out */
.event-banner {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
border-radius: 0 0 12px 12px; /* rounded bottom — it hangs from the top edge */
position: relative;
z-index: 1;
border-left: 4px solid rgba(255,255,255,0.25);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
cursor: pointer; /* whole banner is click-to-close */
pointer-events: auto; /* re-enable clicks on the panel itself */
}
.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-emoji {
font-size: 24px;
flex-shrink: 0;
line-height: 1;
}
.event-banner-body {
flex: 1;
min-width: 0;
}
.event-banner-title {
font-family: Fraunces, serif;
font-style: italic;
font-weight: 700;
font-size: 16px;
line-height: 1.2;
margin-bottom: 3px;
}
.event-banner-msg {
font-family: Manrope, sans-serif;
font-size: 12.5px;
line-height: 1.55;
opacity: 0.88;
}
.event-banner-dismiss {
background: transparent;
border: none;
cursor: pointer;
font-size: 18px;
opacity: 0.55;
padding: 4px 6px;
line-height: 1;
flex-shrink: 0;
transition: opacity 0.15s;
}
.event-banner-dismiss:hover {
opacity: 1;
}
/* Slideshow dots */
.event-banner-dots {
display: flex;
gap: 10px;
margin-top: 7px;
align-items: center;
padding: 5px 0;
}
.event-banner-dot {
width: 10px;
height: 10px;
border-radius: 50%;
opacity: 0.35;
cursor: pointer;
transition: opacity 0.2s, transform 0.2s;
flex-shrink: 0;
}
.event-banner-dot.active {
opacity: 1;
transform: scale(1.3);
}
.event-banner-dot:hover {
opacity: 0.75;
}
/* Cell tag — event icon in the hour column */
.event-cell-tag {
font-size: 15px;
@@ -312,46 +182,6 @@
}
}
/* ── EVENT BANNER — mobile fixes ────────────────────────────────────── */
@media (max-width: 640px) {
.event-banner-wrap {
padding: 0; /* full-bleed flyout on small screens */
}
.event-banner {
align-items: flex-start; /* stop dismiss ✕ drifting downward */
gap: 10px;
padding: 18px 12px 14px 14px;
margin-bottom: 0;
border-radius: 0 0 12px 12px;
}
.event-banner-emoji {
font-size: 20px;
margin-top: 3px; /* visually balance the dates-line margin-top below */
}
.event-banner-title {
font-size: 15px;
line-height: 1.25;
margin-bottom: 3px;
}
.event-banner-msg {
font-size: 12px;
line-height: 1.5;
}
.event-banner-dismiss {
margin-top: 0; /* keep ✕ pinned flush to the top-right */
padding: 0 2px;
font-size: 16px;
line-height: 1.4;
flex-shrink: 0;
align-self: flex-start;
}
}
/* ── CUSTOM SELECT DROPDOWN ─────────────────────────────────────────── */
/* Cross-platform replacement for native <select> on the col-toggles bar */
+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>`}
+6 -4
View File
@@ -7,7 +7,7 @@
// Exports (in order of appearance):
// CustomSelect({ value, options, onChange, ... }) styled dropdown pill
// VentPill({ checked, onChange, label, title }) checkbox fused to pill
// RowStepper({ value, onChange, showLabel }) table row-interval stepper
// RowStepper({ value, onChange, showLabel, label }) table row-interval stepper
// SkyScope({ elev, dt, glob, size }) small porthole per hour
// WindVane({ bearing, size }) compass-arrow widget
// CloudIcon({ category, size, elev, dt }) cloud/sun glyph
@@ -271,16 +271,18 @@ export function VentPill({ checked, onChange, label, title, grpClass }) {
// props:
// value - 1..4
// onChange - fn(n)
// showLabel - draw the "Rows" caption (off inside the Hour header,
// showLabel - draw the caption (off inside the table's Rows header,
// where the column title already supplies the context)
// label - caption text: "Rows" by default, "Hours" in quick view,
// where each card is an hour rather than a table row
// min/max - bounds, so 6h/12h can be added without a redesign
// -------------------------------------------------------------------
export function RowStepper({ value, onChange, showLabel = true, min = 1, max = 4 }) {
export function RowStepper({ value, onChange, showLabel = true, label = 'Rows', min = 1, max = 4 }) {
const v = Math.min(max, Math.max(min, Number(value) || min));
const step = (n) => { const next = Math.min(max, Math.max(min, n)); if (next !== v) onChange(next); };
return html`
<span class="row-stepper" role="group" aria-label="Row interval">
${showLabel && html`<span class="row-stepper-label">Rows</span>`}
${showLabel && html`<span class="row-stepper-label">${label}</span>`}
<button type="button" class="row-stepper-btn" disabled=${v <= min}
title="Finer rows" aria-label="Finer rows - fewer hours per row"
onClick=${() => step(v - 1)}></button>
+29 -12
View File
@@ -154,6 +154,10 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
uv, uvA, uvB,
precip, precipProb, lightning, cape, snow,
soilT0, soilT6, soilM, shadeT,
// Pet Shade reads the same hourly value as Shade but aggregates as a
// MEAN rather than a worst case, so it needs its own field to diverge
// from shadeT at 2h/3h/4h. Same for pawT vs concreteT below.
petShadeT: shadeT,
effectiveRad,
elev, Tmrt, utci, utciAdj, eh, compass,
visKm, aqi,
@@ -182,7 +186,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
const concreteTemps = calcConcreteTempPass(
TaArr, radArr, vaArr, elevArr, uvArr, cloudCatArr, soilMArr, precipArr, snowArr
);
hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; });
hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; r.pawT = concreteTemps[i]; });
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType);
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType);
@@ -267,10 +271,15 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
//
// Every other column is aggregated with the rule that fits its meaning:
// - SUM : precipitation totals (1mm/h x 3h = 3mm)
// - MAX : risk/peak columns (rain %, UV, gusts, cabin/surface/indoor)
// - MEAN : smooth continuous quantities (air temp, RH, wind, cloud, ...)
// - FELT : worst-case felt temp - the warmest hour if it reaches >=20C
// (heat is the concern), otherwise the coldest (cold concern)
// - MAX : risk/peak columns (rain %, UV, gusts, managed indoor)
// - MEAN : smooth continuous quantities (RH, wind, cloud, Tmrt, ...)
// - FELT : worst-case temperature - the warmest hour if the block reaches
// >=20C (heat is the concern), otherwise the coldest hour (cold
// is the concern). Applies to EVERY temperature column the user
// judges comfort or risk by - SunSoak, Cabin, Indoors, Concrete,
// Soil, Shade and Air - so they never disagree with each other
// at 2h/3h/4h. Note Air is therefore a block worst case, not a
// block average, and will read hotter than a mean on a warm day.
//
// Derived render values (Delta, Burn) are recomputed downstream from the
// aggregated Air / UTCI / UV, so they stay consistent automatically.
@@ -278,16 +287,24 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
// interval <= 1 returns the input untouched (zero behaviour change).
// ------------------------------------------------------------------------
const AGG_MEAN = [
'Ta', 'shadeT', 'RH', 'dew', 'va', 'dir', 'dif', 'cc', 'ccLow', 'ccMid', 'ccHigh',
'soilT0', 'soilT6', 'soilM', 'Tmrt', 'eh', 'visKm', 'aqi', 'effectiveRad',
'RH', 'dew', 'va', 'dir', 'dif', 'cc', 'ccLow', 'ccMid', 'ccHigh',
'soilM', 'Tmrt', 'eh', 'visKm', 'aqi', 'effectiveRad',
// Thermal columns that read as a plain block average rather than a worst
// case: Tmrt and Dew above, plus the pet trio. pawT/petShadeT mirror
// concreteT/shadeT hourly but must NOT inherit their FELT aggregation.
'furSurfaceT', 'pawT', 'petShadeT',
'grassPollen', 'birchPollen', 'alderPollen', 'mugwortPollen', 'olivePollen', 'ragweedPollen',
];
const AGG_MAX = [
'precipProb', 'uv', 'uvA', 'uvB', 'gust',
'vehicleT', 'concreteT', 'indoorT', 'managedT',
];
const AGG_MAX = ['precipProb', 'uv', 'uvA', 'uvB', 'gust'];
const AGG_SUM = ['precip', 'snow'];
const AGG_FELT = ['utci', 'utciAdj'];
// Every temperature the user reads as "how hot/cold does this actually get"
// shares one rule, so a 3h row never disagrees with the columns beside it.
const AGG_FELT = [
'utci', 'utciAdj', // SunSoak
'vehicleT', 'concreteT', // Cabin, Concrete
'indoorT', 'managedT', // Indoors + Managed Indoors (+ Pet Home)
'soilT0', 'soilT6', 'shadeT', 'Ta', // Soil surface + root, Shade, Air
];
export function aggregateRows(rows, interval) {
if (!rows || rows.length === 0 || !interval || interval <= 1) return rows;
+10 -3
View File
@@ -29,7 +29,10 @@ export const FILTER_PROFILES = {
label: 'Basic',
icon: '🌡️',
scene: "url('assets/images/profiles/basic.png') center / cover no-repeat, linear-gradient(160deg,#b8d8e8,#daeef8)",
cols: { hour: true, air: true, shadeT: true, rh: true, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
// Basic is the "just tell me" preset - it opens in Quick view (see `view`
// below) with the smallest useful column set behind it.
view: 'simple',
cols: { hour: true, air: false, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: false, lightning: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
},
home: {
label: 'Home',
@@ -53,12 +56,16 @@ export const FILTER_PROFILES = {
label: 'Farming',
icon: '🌾',
scene: "url('assets/images/profiles/farming.png') center / cover no-repeat, linear-gradient(160deg,#d8bc90,#ece0b8)",
// Farming sits in the Work menu alongside the outdoors variants, so it
// opens the detailed table the same way they do.
view: 'table',
cols: { hour: true, air: true, shadeT: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
},
outdoors: {
label: 'Places',
icon: '🌤️',
scene: "url('assets/images/profiles/places.png') center / cover no-repeat, linear-gradient(160deg,#98ccc0,#c4e4dc)",
view: 'table',
// Default cols match the first variant (urban). Switching variant updates visibleCols.
cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
},
@@ -251,9 +258,9 @@ export const workVariantKeys = ['farming', 'construction', 'market', 'windowclea
// Tooltip text shown when the user clicks/hovers a table column header.
export const COL_DESCRIPTIONS = {
hour: { title: 'Hour', desc: 'Local wall-clock time for this forecast row. Each row covers the interval set by the Rows stepper, clock-aligned from midnight — at 3h, a row labelled 09:00 spans 09:0011:59. Totals like rainfall are summed across the block, risk figures show the block\'s peak, and smooth readings like air temperature are averaged.' },
hour: { title: 'Rows', desc: 'Local wall-clock time for this forecast row. Each row covers the interval set by the stepper in this header, clock-aligned from midnight — at 3h, a row labelled 09:00 spans 09:0011:59. Totals like rainfall are summed across the block, risk figures show the block\'s peak, and smooth readings like air temperature are averaged.' },
air: { title: 'Air Temperature', desc: 'Measured air temperature at 2 m above the ground. This is the standard thermometer reading — it does not account for sun, wind, or humidity.' },
shadeT: { title: 'Shade Air', desc: 'Estimated air temperature sitting in the typical shade of the selected Solar Model — building shadow, beach umbrella, tree canopy, boat panels, and so on. Sheltered, sun-warmed surroundings push it above the official open-ground Air figure on calm sunny hours; wind and cloud pull it back toward it. This is an estimate, not a measurement, and differs from the standardised 2 m Air reading.' },
shadeT: { title: 'Shade', desc: 'Estimated air temperature sitting in the typical shade of the selected Solar Model — building shadow, beach umbrella, tree canopy, boat panels, and so on. Sheltered, sun-warmed surroundings push it above the official open-ground Air figure on calm sunny hours; wind and cloud pull it back toward it. This is an estimate, not a measurement, and differs from the standardised 2 m Air reading.' },
rh: { title: 'Relative Humidity', desc: 'How much moisture the air holds relative to its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.' },
dew: { title: 'Dew Point', desc: 'The temperature at which air becomes saturated and moisture begins to condense. A useful measure of absolute humidity — above 16 °C it starts to feel muggy; above 21 °C it feels oppressive.' },
wind: { title: 'Wind Speed', desc: 'Mean wind speed at 10 m in mph, with peak gust in brackets where significantly higher. Gust colour indicates Beaufort scale severity: amber = Near Gale (32+ mph), orange = Gale (39+ mph), red = Severe Gale or above (55+ mph). Banners above the table classify conditions from Strong Breeze through to Violent Storm using standard wind categories. Wind dramatically increases heat loss from exposed skin — the basis of wind chill.' },
+1 -1
View File
@@ -86,7 +86,7 @@ export function getCellTagEvents(events, row) {
// --- BANNER PRIORITY -----------------------------------------------------
// Weather event IDs that are safety-relevant and should always appear as the
// first banner slide, ahead of cosmic events and lower-priority weather items.
const PRIORITY_WEATHER_IDS = new Set(['heat-spike', 'frost', 'storm', 'wind', 'wet']);
export const PRIORITY_WEATHER_IDS = new Set(['heat-spike', 'frost', 'storm', 'wind', 'wet']);
// --- MAIN EXPORT ---------------------------------------------------------
// Returns ALL active events for the given rows/date as an array.
+13 -94
View File
@@ -19,7 +19,7 @@
// 7. Column popup + table scroll hooks
// 8. Geocoding search effect
// 9. Computation - rows, days, current row
// 10. Banner - events, snooze, slideshow
// 10. Events - active, lens, selected-day
// ------------------------------------------------------------------------
import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js';
@@ -336,6 +336,8 @@ export function useAppState() {
const profile = FILTER_PROFILES[key];
try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
setActiveProfile(key);
// Profiles can declare a preferred forecast view (Basic opens in Quick).
if (profile?.view) setForecastView(profile.view);
if (key !== 'custom') {
setVisibleCols({ ...profile.cols });
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
@@ -387,6 +389,10 @@ export function useAppState() {
const setOutdoorsVariantAndSave = (v) => {
try { localStorage.setItem('sunscope_outdoors_variant', v); } catch (e) { /* ignore */ }
setOutdoorsVariant(v);
// Every Places / Activities / Work variant carries a wide, specific column
// set - Quick view can only show a handful of those, so open the detailed
// table. (Basic goes the other way; see FILTER_PROFILES.basic.view.)
setForecastView('table');
// Sync the column toggle buttons to the variants own cols so the UI matches
// the autoloaded selection - eg Beach selects Dew. Sun. UV-B and Running selects RH. Pollen. UTCI.
const variantCols = OUTDOORS_VARIANTS[v]?.cols;
@@ -675,99 +681,15 @@ export function useAppState() {
? utciCategory(currentRow.utciAdj)
: { bg: '#4a4228', fg: '#ede4cc', label: 'No data' };
// ── 10. BANNER + EVENTS ───────────────────────────────────────────────
const [dismissedEventIds, setDismissedEventIds] = useState([]);
const BANNER_SNOOZE_HOURS = 6;
const BANNER_SNOOZE_MS = BANNER_SNOOZE_HOURS * 60 * 60 * 1000;
const BANNER_SNOOZE_KEY = id => `sunscope.banner.snoozed.${id}`;
const [bannerIndex, setBannerIndex] = useState(0);
const [bannerTransition, setBannerTransition] = useState(null);
const [bannerPrevIndex, setBannerPrevIndex] = useState(null);
const [bannerVisible, setBannerVisible] = useState(false);
const bannerIndexRef = useRef(0);
const bannerStageRef = useRef(null);
useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]);
const isSnoozed = id => {
try {
const ts = localStorage.getItem(BANNER_SNOOZE_KEY(id));
return ts && (Date.now() - Number(ts)) < BANNER_SNOOZE_MS;
} catch (e) { return false; }
};
// ── 10. EVENTS ────────────────────────────────────────────────────────
// Events surface as the .event-note strip above the nav (see app.js). It
// is in flow rather than an overlay, so there is no dismiss or snooze
// state to track.
const todayRows = days[0]?.rows || [];
const activeEvents = getActiveEvents(todayRows, location)
.filter(ev => !dismissedEventIds.includes(ev.id) && !isSnoozed(ev.id));
const activeEvents = getActiveEvents(todayRows, location);
const lensEvent = getLensEvent(activeEvents);
const selectedDayEvents = getActiveEvents(visible, location);
// Only show banner when events first appear - not on every re-render
// Using a ref to track previous length avoids re-showing during dismiss animation
const prevActiveEventsLenRef = useRef(0);
useEffect(() => {
const prev = prevActiveEventsLenRef.current;
prevActiveEventsLenRef.current = activeEvents.length;
if (activeEvents.length > 0 && prev === 0) setBannerVisible(true);
}, [activeEvents.length]);
const bannerSlideTo = useCallback((next) => {
const stage = bannerStageRef.current;
if (stage) {
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');
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);
}, []);
const dismissBanner = useCallback((evId) => {
// Do NOT write the snooze to localStorage yet. activeEvents filters out snoozed events
// synchronously on the very next render. so writing here would cause the banner to be
// unmounted before the fade animation can start - which is what made it snap closed.
// We snooze AFTER the animation finishes inside the setTimeout below.
requestAnimationFrame(() => {
setBannerVisible(false);
setTimeout(() => {
try { localStorage.setItem(BANNER_SNOOZE_KEY(evId), String(Date.now())); } catch (e) {}
setDismissedEventIds(ids => [...ids, evId]);
setBannerIndex(0);
}, 500);
});
}, []);
useEffect(() => {
if (activeEvents.length <= 1) { setBannerIndex(0); return; }
const id = setInterval(() => {
const next = (bannerIndexRef.current + 1) % activeEvents.length;
bannerSlideTo(next);
}, 10000);
return () => clearInterval(id);
}, [activeEvents.length, activeEvents.map(e => e.id).join(','), bannerSlideTo]);
useEffect(() => {
if (bannerIndex >= activeEvents.length) setBannerIndex(0);
}, [activeEvents.length]);
// The banner is now a position:fixed flyout pinned to the very top of the
// screen (see .event-banner-wrap in ui.css). It floats over all content and
// slides in/out via a transform, so no JS nav-pinning or shell padding is
// needed.
// ── RETURN ALL STATE + HANDLERS ───────────────────────────────────────
return {
// location
@@ -831,10 +753,7 @@ export function useAppState() {
hourlyRows, days, utcOffsetMs,
visible, tableRows, nowLocalISO, currentRow, currentCat,
liveElev,
// banner + events
// events
activeEvents, lensEvent, selectedDayEvents,
bannerIndex, bannerTransition, bannerPrevIndex,
bannerVisible, bannerStageRef,
bannerSlideTo, dismissBanner,
};
}