Day tab colour update
Cleanup event banner and add close
This commit is contained in:
Fraxle
2026-08-14 10:00:28 +01:00
parent db537411fe
commit dd0ce5b44c
5 changed files with 218 additions and 24 deletions
+49 -7
View File
@@ -276,6 +276,31 @@ export function UTCIForecast() {
setTimeout(() => { setNoteIndex(next); setNoteFading(false); }, NOTE_FADE_MS);
};
// ── Dismissal ────────────────────────────────────────────────────────
// Closing the strip hides it for the rest of the browser session, but only
// for the events that were showing at the time: the dismissal is stored
// against a signature of the active ids, so a new event (or a fresh weather
// warning) brings the strip back rather than staying silently suppressed.
const NOTE_CLOSE_KEY = 'sunscope_event_note_closed';
const NOTE_CLOSE_MS = 260;
const noteSig = activeEvents.map(e => e.id).sort().join('|');
const [noteClosedSig, setNoteClosedSig] = useState(() => {
try { return sessionStorage.getItem(NOTE_CLOSE_KEY) || ''; } catch (e) { return ''; }
});
// Kept mounted for the collapse animation, then dropped.
const [noteClosing, setNoteClosing] = useState(false);
const noteDismissed = !!noteSig && noteSig === noteClosedSig;
const noteClose = () => {
if (noteClosing) return;
setNoteClosing(true);
setTimeout(() => {
try { sessionStorage.setItem(NOTE_CLOSE_KEY, noteSig); } catch (e) { /* ignore */ }
setNoteClosedSig(noteSig);
setNoteClosing(false);
}, NOTE_CLOSE_MS);
};
// Keep the index in range if the event list shrinks between forecasts.
useEffect(() => {
if (noteIndex >= activeEvents.length) setNoteIndex(0);
@@ -669,8 +694,8 @@ export function UTCIForecast() {
// • "utci-about" — the explainer paragraphs at the bottom
// • "utci-footer" — the "reading the table" note
return html`
<div class=${`utci-app${activeEvents.length > 0 ? ' has-event-note' : ''}`}>
${activeEvents.length > 0 && (() => {
<div class=${`utci-app${activeEvents.length > 0 && !noteDismissed ? ' has-event-note' : ''}`}>
${activeEvents.length > 0 && !noteDismissed && (() => {
const ev = activeEvents[noteIndex] || activeEvents[0];
const isPriority = PRIORITY_WEATHER_IDS.has(ev.id);
const fmtDate = (iso) => iso
@@ -703,7 +728,7 @@ export function UTCIForecast() {
};
})() : undefined;
return html`
<div class=${`event-note${isPriority ? ' is-priority' : ''}${noteTintStyle ? ' is-tinted' : ''}`} style=${noteTintStyle}>
<div class=${`event-note${isPriority ? ' is-priority' : ''}${noteTintStyle ? ' is-tinted' : ''}${noteClosing ? ' is-closing' : ''}`} style=${noteTintStyle}>
<span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
${/* Every slide is rendered, all stacked in one CSS grid cell, so the
strip is always as tall as the LONGEST event's copy. Rotating to
@@ -720,10 +745,14 @@ export function UTCIForecast() {
class=${`event-note-slide${isActive ? ' is-active' : ''}`}
aria-hidden=${isActive ? undefined : 'true'}
>
<span class="event-note-emoji">${e.emoji}</span>
<span class="event-note-title">${e.title}</span>
<span class="event-note-msg">${e.message}</span>
${dateLine && html`<span class="event-note-dates">${dateLine}</span>`}
<div class="event-note-header">
<span class="event-note-emoji">${e.emoji}</span>
<span class="event-note-title">${e.title}</span>
</div>
<div class="event-note-body">
<span class="event-note-msg">${e.message}</span>
${dateLine && html`<span class="event-note-dates">${dateLine}</span>`}
</div>
</div>`;
})}
</div>
@@ -738,6 +767,19 @@ export function UTCIForecast() {
onClick=${() => noteGoTo(i)}
/>`)}
</div>`}
<button
type="button"
class="event-note-close"
aria-label="Close event banner"
title="Close"
onClick=${noteClose}
>
<span class="event-note-close-label">Close</span>
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
<path d="M1.5 1.5 L10.5 10.5 M10.5 1.5 L1.5 10.5"
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>`;
})()}
<nav class="utci-topnav" id="site-nav">
+35 -14
View File
@@ -33,7 +33,7 @@
import { h, Fragment } from '../../vendor/preact.js';
import htm from '../../vendor/htm.js';
import { confidenceBand, utciCategory, petCategory, hexToRgb, mixRgb, rainTint, cloudTint, snowTint, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
import { confidenceBand, utciCategory, petCategory, UTCI_BANDS, PET_BANDS, bandRampRgb, hexToRgb, mixRgb, rainTint, cloudTint, snowTint, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js';
import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js';
import { SubscribeModal } from './SubscribeModal.js';
@@ -292,6 +292,14 @@ export function DayTabs({
// event banner can colour itself from exactly the same source.
const catFn = isPetsProfile ? petCategory : utciCategory;
const tBand = dayHi !== null ? catFn(dayHi) : { bg: '#f8e554' };
// Band colour, but ramped: bandRampRgb() interpolates between the
// this band's colour and the next one's, so 30° isn't the same
// swatch as 27° while 32° still lands on the Extreme orange
// (see BAND COLOUR RAMP in utils.js). Danger stays flat — it's an
// alarm colour, not a point on the gradient.
const tBandRgb = tBand.solid
? hexToRgb(tBand.bg)
: bandRampRgb(dayHi, isPetsProfile ? PET_BANDS : UTCI_BANDS);
const ccVals = repRows.map(r => r.cc).filter(v => isFinite(v));
const avgCloud = ccVals.length ? ccVals.reduce((s, v) => s + v, 0) / ccVals.length : 0;
@@ -314,7 +322,7 @@ export function DayTabs({
let rgb;
if (tempOnly) {
rgb = hexToRgb(tBand.bg);
rgb = tBandRgb;
} else if (isHot) {
// Keep the heat hue; cloud only mutes it a touch (orange/red never
// greens) and rain does not override heat.
@@ -322,7 +330,7 @@ export function DayTabs({
? (dayHi >= 49 ? 0.15 : dayHi >= 43 ? 0.30 : 0.45)
: (dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45);
const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor;
rgb = mixRgb(hexToRgb(tBand.bg), [170, 162, 152], t);
rgb = mixRgb(tBandRgb, [170, 162, 152], t);
} else if (daySnow >= 0.1) {
// Snow: pale → icy blue with depth.
rgb = snowTint(daySnow);
@@ -334,7 +342,7 @@ export function DayTabs({
rgb = cloudTint(avgCloud);
} else if (dayHi !== null && dayHi < (isPetsProfile ? 2 : 10)) {
// Clear & cold: keep the cold temperature blue.
rgb = hexToRgb(tBand.bg);
rgb = tBandRgb;
} else {
// Clear & mild/warm: a plain sunny yellow.
rgb = hexToRgb('#f8e554');
@@ -350,16 +358,29 @@ export function DayTabs({
}
const [wR, wG, wB] = rgb;
const wBgNeutral = isDanger
? weatherGradientNeutral(wR, wG, wB, 0.15, 0.72)
: isExtreme
? weatherGradientNeutral(wR, wG, wB, 0.20, 0.66)
: weatherGradientNeutral(wR, wG, wB);
const wBgActive = isDanger
? weatherGradientActive(wR, wG, wB, 0.12, 0.72)
: isExtreme
? weatherGradientActive(wR, wG, wB, 0.16, 0.66)
: weatherGradientActive(wR, wG, wB);
// How pastel the tab is drawn also carries heat: the tint is blended
// toward white, and the hotter the day the less white goes in. This
// used to step at the band edges (0.50 for everything up to Caution,
// then 0.20 at Extreme), which made 30° look like a washed-out yellow
// while 32° snapped to a solid orange. Ramp the blend continuously
// instead — the endpoints still land on the old Extreme (heatT = 1)
// and Danger (both = 1) values, so only the in-between days change.
const heatFloor = isPetsProfile ? 35 : 27; // start of the hot half
const heatMid = isPetsProfile ? 40 : 32; // Extreme threshold
const heatTop = isPetsProfile ? 52 : 41; // Danger threshold
const clamp01 = (v) => Math.max(0, Math.min(1, v));
const heatT = dayHi === null ? 0 : clamp01((dayHi - heatFloor) / (heatMid - heatFloor));
const overT = dayHi === null ? 0 : clamp01((dayHi - heatMid) / (heatTop - heatMid));
const wBgNeutral = weatherGradientNeutral(
wR, wG, wB,
0.50 - 0.30 * heatT - 0.05 * overT, // edge: 0.50 → 0.20 → 0.15
0.69 - 0.03 * heatT + 0.06 * overT, // centre: 0.69 → 0.66 → 0.72
);
const wBgActive = weatherGradientActive(
wR, wG, wB,
0.42 - 0.26 * heatT - 0.04 * overT, // edge: 0.42 → 0.16 → 0.12
0.64 + 0.02 * heatT + 0.06 * overT, // centre: 0.64 → 0.66 → 0.72
);
const wText = '#2a1d10';
// Active-tab outline: the day's weather colour, 20% darker.
const wOutline = `rgb(${Math.round(wR * 0.8)},${Math.round(wG * 0.8)},${Math.round(wB * 0.8)})`;
+52
View File
@@ -56,6 +56,58 @@ export function utciCategory(u) {
}
}
// -------------------------------------------------------------------
// BAND COLOUR RAMP - continuous version of the banded palette.
// -------------------------------------------------------------------
// utciCategory()/petCategory() hand back ONE flat colour per band, which
// is right for table cells (a cell states which band you are in) but wrong
// for anything that should read as a temperature gradient: every day from
// 27 -C to 31.9 -C is the same "Caution" swatch, so the only visible change
// is the hard jump at the next threshold.
//
// bandRampRgb() anchors each band's colour at the BOTTOM of that band's
// range and interpolates toward the next band's colour across the band. So
// a temperature sitting exactly on a threshold still gets that band's own
// published colour (32 -C is still the Extreme orange), and every degree
// above it slides proportionally toward the next tier - 30 -C lands 60% of
// the way from Caution to Extreme instead of being identical to 27 -C.
//
// The solid Danger band is left out of the ramp: it's a flat stop-everything
// alarm colour, not part of the gradient, so the ramp clamps at the last
// graded band and callers keep handling Danger separately.
// -------------------------------------------------------------------
const rampAnchorCache = new WeakMap();
function rampAnchors(bands) {
let anchors = rampAnchorCache.get(bands);
if (anchors) return anchors;
anchors = [];
let lower = null;
for (const band of bands) {
if (band.solid) break;
// The first band is open-ended downward; give it a nominal 10 -C span so
// it still has an anchor below its own threshold.
anchors.push({ at: lower === null ? band.max - 10 : lower, rgb: hexToRgb(band.bg) });
if (band.max === undefined) break;
lower = band.max;
}
rampAnchorCache.set(bands, anchors);
return anchors;
}
export function bandRampRgb(t, bands) {
const anchors = rampAnchors(bands);
if (t === null || !isFinite(t)) return anchors[0].rgb.slice();
if (t <= anchors[0].at) return anchors[0].rgb.slice();
const last = anchors[anchors.length - 1];
if (t >= last.at) return last.rgb.slice();
for (let i = 1; i < anchors.length; i++) {
const a = anchors[i - 1], b = anchors[i];
if (t <= b.at) return mixRgb(a.rgb, b.rgb, (t - a.at) / (b.at - a.at));
}
return last.rgb.slice();
}
// -------------------------------------------------------------------
// WEATHER TINT PALETTE - single source of truth for "what colour is
// this weather", shared by the day tabs (components/DayTabs.js) and the