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
+1
View File
@@ -30,3 +30,4 @@ dist/
*.ps1
*.py
secrets.local.php
/data
+81 -3
View File
@@ -272,15 +272,27 @@ body {
.event-note-slide {
grid-area: 1 / 1; /* stack all slides on top of each other */
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 10px;
flex-direction: column;
gap: 2px;
min-width: 0;
opacity: 0; /* only .is-active is visible */
pointer-events: none;
user-select: none; /* keeps hidden copy out of a drag-selection */
transition: opacity 0.35s ease;
}
.event-note-header {
display: flex;
align-items: baseline;
gap: 6px;
}
.event-note-body {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.event-note-slide.is-active {
opacity: 1;
pointer-events: auto;
@@ -351,6 +363,63 @@ body {
transform: scale(1.3);
}
/* Close control — a small brass "Close ×" pill in the top-right corner. It
sits at the end of the strip's flex row rather than being absolutely
positioned, so the dates line can never run underneath it. */
.event-note-close {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
align-self: flex-start;
margin: -2px -6px 0 2px; /* pull into the padding so it hugs the corner */
padding: 3px 7px;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9a7d5a;
line-height: 1;
cursor: pointer;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.event-note-close:hover {
background: rgba(200, 146, 42, 0.14);
border-color: rgba(200, 146, 42, 0.45);
color: #7a5a1e;
}
.event-note-close:focus-visible {
outline: 2px solid #c8922a;
outline-offset: 1px;
}
.event-note.is-priority .event-note-close {
color: #a06e18;
}
.event-note.is-tinted .event-note-close {
color: #56452c;
}
.event-note.is-tinted .event-note-close:hover {
background: rgba(0, 0, 0, 0.09);
border-color: rgba(0, 0, 0, 0.22);
color: #2a1a08;
}
/* Collapse on the way out — height and vertical spacing animate to zero so
the page closes the gap smoothly instead of snapping upward. */
.event-note.is-closing {
animation: event-note-out 0.26s ease forwards;
pointer-events: none;
}
@keyframes event-note-out {
from { opacity: 1; transform: translateY(0); max-height: 200px; }
to { opacity: 0; transform: translateY(-4px); max-height: 0;
margin-bottom: 0; padding-top: 0; padding-bottom: 0; border-width: 0; }
}
/* 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. */
@@ -376,6 +445,15 @@ body {
.event-note-dates {
margin-left: 0; /* no room to right-align on a phone */
}
/* Phone: the cross alone, pushed to the right-hand end of the row and
padded out to a ~26px touch target. */
.event-note-close {
margin: -4px -6px 0 auto;
padding: 8px;
}
.event-note-close-label {
display: none;
}
}
/* Nav logo — hidden on desktop, shown on mobile */
+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