Restructure
New Journal entries
Colour tabs update with wind
This commit is contained in:
fraxle
2026-08-31 15:12:45 +01:00
parent 47818fba33
commit 71860b9dd9
40 changed files with 3430 additions and 443 deletions
+533 -66
View File
@@ -32,10 +32,11 @@
// ------------------------------------------------------------------------
import { h, Fragment } from '../../vendor/preact.js';
import { useEffect, useRef } from '../../vendor/preact-hooks.js';
import htm from '../../vendor/htm.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 { confidenceBand, utciCategory, petCategory, DAY_TAB_UTCI_BANDS, DAY_TAB_PET_BANDS, bandRampRgb, hexToRgb, mixRgb, rainTint, 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 { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon, WindIcon } from '../components.js';
import { SubscribeModal } from './SubscribeModal.js';
const html = htm.bind(h);
@@ -114,6 +115,82 @@ function sustainedHigh(vals) {
return sorted[Math.min(drop, sorted.length - 1)];
}
// Lays a sky tint (rain blue, snow blue, cloud grey) over a temperature colour
// without the blend passing through green on the way.
//
// A straight mixRgb() from the Warm band's yellow to a rain blue travels
// through olive and then green — which is why the tabs used to pick ONE
// dimension by priority instead of blending. Desaturating the base toward its
// own luminance first routes yellow → khaki → grey → blue instead, so the
// temperature can genuinely shade toward the weather without inventing a hue
// that belongs to neither.
// base - [r,g,b] temperature colour from the band ramp
// target - [r,g,b] weather tint (rainTint / snowTint)
// w - 0..1 how strongly the weather pulls
function overlayTint(base, target, w) {
if (!(w > 0)) return base;
// The pre-desaturation exists ONLY to keep a hue-to-hue blend off the green
// diagonal — yellow travelling to rain-blue would otherwise pass through
// olive. A target with no hue of its own has no such diagonal to avoid, and
// mixing toward a grey already desaturates by definition, so applying the
// full guard to the cloud tint stripped the base colour twice over. Scale it
// by how much hue the target actually carries: rain (a saturated blue) keeps
// essentially all of it, cloud (a near-neutral grey) almost none.
const chroma = (Math.max(...target) - Math.min(...target)) / 255;
const hueGuard = Math.min(1, chroma * 2.2);
const lum = 0.299 * base[0] + 0.587 * base[1] + 0.114 * base[2];
const desat = mixRgb(base, [lum, lum, lum], Math.min(1, w * 0.9 * hueGuard));
return mixRgb(desat, target, w);
}
// Cloud is applied differently from rain, and deliberately so.
//
// Rain has a hue of its own — a day IS blue-grey with rain — so it earns a
// blend toward that colour. Cloud does not: an overcast 20° day is still a 20°
// day, just duller. Blending toward a grey HUE meant every warm overcast tab
// had to travel from gold to grey, and the route passes through khaki: at a
// middling weight #edd449 lands on #b4a85f, the muddy olive that reads as
// neither temperature nor weather. Pushing the weight high enough to clear the
// mud (0.80) instead erased the temperature entirely, which is how every day
// from +4° to +23° ended up the same grey.
//
// There is no weight that avoids both, because the problem is the path, not
// the distance. So cloud no longer moves the hue at all: it desaturates the
// temperature colour toward its OWN luminance and dims it slightly. Same hue,
// less vivid, a little darker — which is what overcast actually looks like —
// and because it never crosses between two hues there is no muddy middle at
// any weight.
function cloudDim(rgb, w) {
if (!(w > 0)) return rgb;
// Desaturate toward a neutral that is never DARKER than the colour it came
// from, and lift it slightly. This matters more than it looks: a darkened
// desaturated yellow is olive — that is simply what olive is — so the
// earlier version, which dulled toward the colour's own luminance and then
// dimmed 16%, walked the gold Comfortable band straight down into khaki
// (#edd449 -> #cabc75 -> #b3ad8c) and put green back on the strip by a
// different route than the palette had.
//
// Overcast is diffuse and flat, not dark, so lifting rather than dimming is
// also the truer look: gold travels gold -> cream -> pale sand
// (#e3d799 -> #e2dbb4), never through olive, and the blues at the cold end
// pale out the same way.
const lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
const neutral = Math.min(255, lum + 14);
return mixRgb(rgb, [neutral, neutral, neutral], w);
}
// Weather weights are pushed through this before they are used.
//
// A linear weight means a day with middling rain sits at a 50/50 mix of
// temperature colour and rain blue — which is a muddy olive that reads as
// neither, and the whole point of the tab shade is that someone can tell what
// the day is at a glance. Smoothstep drags low weights lower and high weights
// higher, so the colour spends as little time as possible in that ambiguous
// middle: a bit of weather barely disturbs the temperature colour, and weather
// that genuinely IS the day takes the tab almost completely.
const clamp01 = (v) => Math.max(0, Math.min(1, v));
const decisive = (t) => { const c = clamp01(t); return c * c * (3 - 2 * c); };
function weatherGradientActive(r, g, b, edgeBlend = 0.42, centerBlend = null) {
const blend = (c, amt) => Math.round(c + (255 - c) * amt);
const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38));
@@ -179,10 +256,34 @@ export function DayTabs({
: utciEnv;
const rowGradient = ROW_GRADIENTS[mainConfigKey]?.[rowOptionKey];
// The row pins to the top of the viewport on wide screens, and the table's
// sticky hour header has to stop just below it. Publish the measured height
// (plus its 4px margin) as --profile-row-h rather than hard-coding one in
// table.css: the row is one line or two depending on how long the profile
// and thermal-basis labels are, so a fixed offset leaves the header tucked
// under it.
const profileRowRef = useRef(null);
useEffect(() => {
const el = profileRowRef.current;
if (!el) return;
const publish = () => {
const h = Math.round(el.getBoundingClientRect().height) + 4;
document.documentElement.style.setProperty('--profile-row-h', `${h}px`);
};
publish();
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(publish) : null;
if (ro) ro.observe(el);
window.addEventListener('resize', publish);
return () => {
if (ro) ro.disconnect();
window.removeEventListener('resize', publish);
};
}, []);
return html`
<${Fragment}>
<div class=${`active-profile-row ${rowGrpClass}`} onClick=${openPanel} role="button" tabIndex="0"
<div ref=${profileRowRef} class=${`active-profile-row ${rowGrpClass}`} onClick=${openPanel} role="button" tabIndex="0"
onKeyDown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); } }}
style=${rowGradient ? { '--row-bg': rowGradient } : undefined}
title="Change profile & settings">
@@ -244,6 +345,12 @@ export function DayTabs({
const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null;
const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null;
// Day-tab weather icon - use core daylight rows (elev > 10°) where
// available, falling back to any above-horizon rows, then all rows.
const coreRows = d.rows.filter(r => r.elev > 10);
const aboveRows = d.rows.filter(r => r.elev > 0);
const repRows = coreRows.length > 0 ? coreRows : aboveRows.length > 0 ? aboveRows : d.rows;
// The tab COLOUR is keyed to a spike-resistant "sustained high" rather than
// the raw max, so a single warm hour on a cool/damp day doesn't paint the
// whole tab warm. The Danger tier is the exception — it keeps alarming on
@@ -252,8 +359,68 @@ export function DayTabs({
// always show the true dayHi/dayLo.
const catFn = isPetsProfile ? petCategory : utciCategory;
const isDanger = dayHi !== null && catFn(dayHi).solid === true;
// Spike-filter the DAYLIGHT hours, not the whole 24. Run over every
// row, sustainedHigh() drops the top ~10% of a 24-hour list — two or
// three hours — and on a summer day with a big diurnal swing that is
// the entire afternoon peak, not a spike. A 28° day was colouring
// from ~25°, a whole band down from the number printed on the tab.
// repRows is already what the icon, cloud and rain read, so the hue
// now comes from the same slice of the day as everything else.
const colourVals = repRows.map(r => r[mainField]).filter(v => isFinite(v));
// On a cold day the sustained HIGH is the wrong representative.
// Heat is a PEAK risk — the hottest hours are the dangerous ones, so
// colouring from the high is right at the top of the scale. Cold is
// an EXPOSURE risk: what matters is how cold it stayed all day, not
// the one mild moment. Invercargill on 31 Aug peaked at -4.8° at 6pm
// against daylight hours running -6.5° to -18.6°, so colouring from
// the peak put a pale Freezing tab on a genuinely Arctic day.
//
// Below 10° the colour therefore crossfades from the sustained high
// toward the daylight MEAN, arriving fully at the mean by 6°. It is
// crossfaded rather than switched so two adjacent days either side of
// the threshold can't jump a band against each other in the strip.
// The printed hi/lo numbers are untouched — they stay the true peak
// and trough, as they already do for the spike filter.
const colourAvg = colourVals.length
? colourVals.reduce((s, v) => s + v, 0) / colourVals.length
: null;
const sustHi = sustainedHigh(colourVals.length ? colourVals : mainVals);
const coldWeight = colourAvg === null ? 0 : clamp01((10 - colourAvg) / 4);
const colourHi = dayHi === null ? null
: (isDanger ? dayHi : Math.round(sustainedHigh(mainVals)));
: isDanger ? dayHi
: Math.round(sustHi + (colourAvg - sustHi) * coldWeight);
// ---- Which hours the tab's COLOUR should read the sky from -------
// The tint used to average cloud and total rain across every daylight
// hour, which silently assumes the day is all one weather. Split days
// break that badly: a heavy wet morning followed by a hot, clear
// afternoon totals up as "wet and overcast", so the tab got painted
// rain-blue even though the hours it is printing a temperature FOR
// were dry and sunny.
//
// The tab's number already comes from the hottest sustained hours, so
// the shading should come from the same ones. colourRows is that
// slice — daylight hours within 3° of colourHi, never fewer than the
// three hottest — and it drives the tint only.
//
// The ICON deliberately keeps reading the whole day: it did rain this
// morning, and the glyph is the right place to say so. Colour answers
// "how hot is this day", the glyph answers "did it rain".
const colourRows = (() => {
if (colourHi === null || !repRows.length) return repRows;
const near = repRows.filter(r => isFinite(r[mainField]) && r[mainField] >= colourHi - 3);
if (near.length >= 3) return near;
return [...repRows]
.filter(r => isFinite(r[mainField]))
.sort((a, b) => b[mainField] - a[mainField])
.slice(0, 3);
})();
const peakPrecip = colourRows.reduce((s, r) => s + (r.precip || 0), 0);
const peakSnow = colourRows.reduce((s, r) => s + (r.snow || 0), 0);
const peakProbs = colourRows.map(r => r.precipProb).filter(v => isFinite(v));
const peakProb = peakProbs.length ? Math.max(...peakProbs) : 0;
const peakWet = clamp01((peakProb - 50) / 25);
// Secondary row under each hi/lo: Shade for SunSoak, Managed for
// indoor, Pet Shade for pets, and plain air temperature for
@@ -265,11 +432,6 @@ export function DayTabs({
shadeLo = secondaryVals.length ? Math.round(Math.min(...secondaryVals)) : null;
}
// Day-tab weather icon - use core daylight rows (elev > 10°) where
// available, falling back to any above-horizon rows, then all rows.
const coreRows = d.rows.filter(r => r.elev > 10);
const aboveRows = d.rows.filter(r => r.elev > 0);
const repRows = coreRows.length > 0 ? coreRows : aboveRows.length > 0 ? aboveRows : d.rows;
const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0);
const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0);
const catCounts = {};
@@ -279,7 +441,43 @@ export function DayTabs({
const noonRow = repRows.reduce((best, r) => (r.elev > (best?.elev ?? -Infinity) ? r : best), null);
const repElev = noonRow ? noonRow.elev : 45;
const repDt = noonRow ? noonRow.dt : dDate;
const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1;
// ---- The two rain numbers, reconciled --------------------------
// The forecast gives us two different things and they answer
// different questions: precipProb is WHETHER it rains, precip is HOW
// MUCH if it does. Reading either one alone gets the day wrong — an
// amount-only reading puts a rain icon on a 37% day that will most
// likely stay dry, while a probability-only reading can't tell
// drizzle from a downpour.
//
// So: probability decides IF the tab shows rain at all, and then the
// two together decide how hard that rain looks.
// • under 50% — no rain icon; it probably won't rain
// • 50% → 100% — rain icon, climbing to medium-high on
// confidence alone
// • + heavy amount — climbs the rest of the way to a downpour
// The tab COLOUR reuses the same 50% gate, but reads it over the peak
// hours (peakWet, above), so the icon and the shade can never
// disagree about WHETHER it rains — only about whether it was still
// raining during the part of the day the tab is printing a
// temperature for.
const probVals = repRows.map(r => r.precipProb).filter(v => isFinite(v));
const maxProb = probVals.length ? Math.max(...probVals) : 0;
// Confidence half: 0 at the 50% gate, 1 at a dead-certain 100%.
const probT = clamp01((maxProb - 50) / 50);
// Amount half: the daylight total, "heavy" taken as 8mm across the day.
const amountT = clamp01(dayPrecip / 8);
// Weighted so certainty alone tops out around medium-high (2 drops)
// and only a genuinely wet forecast reaches the 3-drop downpour.
const rainScore = probT * 0.55 + amountT * 0.45;
const rainDrops = rainScore < 0.33 ? 1 : rainScore < 0.70 ? 2 : 3;
// Snow gets the same probability gate — precipProb covers all
// precipitation — but keeps its own amount-driven flake count.
const showSnow = daySnow >= 0.1 && maxProb >= 50;
const showRain = !showSnow && dayPrecip >= 0.3 && maxProb >= 50;
const showPrecip = showRain || showSnow;
// Vehicle tab: swap the default car glyph for a hazard icon when
// conditions could affect driving. Uses the full day (not just
@@ -305,12 +503,65 @@ export function DayTabs({
}
}
// Day-tab colour — PRIORITY model, no hue blending.
// Mixing a warm yellow with grey/blue passes through green, so rather
// than blend temperature with sky we pick ONE dimension by priority and
// only vary its shade: heat → snow → rain → cloud → sun (first wins).
// • Hot/extreme days always keep their heat colour (a safety signal).
// • Otherwise rain wins, then cloud, then a clear "sunny"/cold colour.
// Wind badge — gusts over the WHOLE day, not just the daylight
// repRows: a gale that runs into the evening still defines the day.
// Tiers mirror windCategory() in events/weather-checks.js so the tab
// and the At a Glance advisory can never disagree about what counts
// as a gale.
const gustVals = d.rows.map(r => r.gust ?? r.va).filter(v => isFinite(v));
const gustsMph = gustVals.map(v => v * 2.237);
// Round BEFORE tiering, not after. The table's gust column prints a
// rounded figure, so a 31.99 mph day already reads "32 mph" there —
// testing the raw value against a 32 mph threshold then withheld the
// badge from a day the rest of the UI was calling a 32 mph day.
// Tier the same number the user is actually shown.
const maxGust = gustsMph.length ? Math.round(Math.max(...gustsMph)) : 0;
const avgGust = gustsMph.length
? gustsMph.reduce((s, v) => s + v, 0) / gustsMph.length : 0;
// A day doesn't have to spike to be a windy day. Peak-only tiering
// missed the kind people actually notice — hour after hour of 25-30
// mph gusts, a Strong Breeze (Force 6) that never quite reaches Near
// Gale — while a day with one brief 32 mph gust between calm hours
// got the badge. So a day that stays blustery for at least half its
// hours earns the badge on its own. 25 mph is GUST_BLUSTERY in
// events/weather-checks.js.
const blusteryFrac = gustsMph.length
? gustsMph.filter(v => v >= 25).length / gustsMph.length : 0;
const sustainedWind = blusteryFrac >= 0.5;
const windTier = maxGust >= 74 ? 5 : maxGust >= 55 ? 4
: maxGust >= 39 ? 3 : maxGust >= 32 ? 2
: sustainedWind ? 2 : 0;
const windLabel = windTier >= 5 ? 'Storm force'
: windTier >= 4 ? 'Severe gale'
: windTier >= 3 ? 'Gale'
: maxGust >= 32 ? 'Near gale'
: 'Blustery';
// Badge size tracks what the day AVERAGED rather than how hard it
// spiked, so a day that simply stays windy reads heavier than one
// carried by a single gust. 18 mph averages to the smallest badge,
// 34 mph to the largest.
const windBadgeSize = Math.round(15 + 9 * clamp01((avgGust - 18) / 16));
// Day-tab colour — WEIGHTED model.
// Temperature is always the base: the band ramp, so the tab reads as a
// temperature first and a 17° day can never come out looking like a
// 26° one. Sky conditions then shade that base by influence, in order
// of how much they change the day:
// 1. temperature - base colour, from the band ramp
// 2. cloud cover - dulls that colour in place, same hue
// 3. rain / snow - pulls toward blue, by amount AND confidence
// Thermal first, then cloud, then precipitation. Cloud is the sky
// condition EVERY day has some of, so it is the general case and rain
// is the special one layered over it — and crucially it has to be
// cloudy for it to rain, so rain never lands on a raw band colour.
// By the time rain applies, cloud has already carried the tab to
// grey, and grey is the one starting point from which deep rain-blue
// is a clean path: gold straight to blue goes through khaki and
// olive, gold to grey to blue does not. That ordering is what keeps
// green off the strip, not a choice of weights.
// Rain and snow blend through overlayTint() so a warm yellow shading
// toward rain-blue routes via grey rather than passing through green;
// cloud uses cloudDim(), which never moves the hue at all.
// Shade ramps live in utils.js (WEATHER TINT PALETTE) so the warning
// event banner can colour itself from exactly the same source.
const tBand = colourHi !== null ? catFn(colourHi) : { bg: '#f8e554' };
@@ -321,17 +572,71 @@ export function DayTabs({
// alarm colour, not a point on the gradient.
const tBandRgb = tBand.solid
? hexToRgb(tBand.bg)
: bandRampRgb(colourHi, isPetsProfile ? PET_BANDS : UTCI_BANDS);
: bandRampRgb(colourHi, isPetsProfile ? DAY_TAB_PET_BANDS : DAY_TAB_UTCI_BANDS);
const ccVals = repRows.map(r => r.cc).filter(v => isFinite(v));
// Cloud for the TINT, averaged over the peak hours only — see
// colourRows above. A wet overcast morning no longer greys out a
// clear hot afternoon.
const ccVals = colourRows.map(r => r.cc).filter(v => isFinite(v));
const avgCloud = ccVals.length ? ccVals.reduce((s, v) => s + v, 0) / ccVals.length : 0;
// Hot/cold cutoffs below are calibrated against UTCI_BANDS; for the
// Pets profile (furSurfaceT, judged against the shifted PET_BANDS)
// the same cutoffs are re-projected onto the pet scale so the
// "hot hue" / "plain cold" branches kick in at the same relative
// point in each band, not the same absolute degree.
const isHot = colourHi !== null && colourHi >= (isPetsProfile ? 35 : 27); // Hot band and above
// Heat cutoffs are calibrated against the UTCI bands; for the Pets
// profile (furSurfaceT, judged against the shifted pet bands) the same cutoffs
// are re-projected onto the pet scale so each one kicks in at the same
// relative point in the band, not the same absolute degree.
// heatT drives both how far the sky is allowed to shade the colour
// (skyCap, below) and how pastel the tab is drawn (further down).
// heatFloor is the START of the hot half — the Warm band threshold,
// not the Caution one. Anchoring it at Caution meant a day sitting in
// the bottom of Caution scored heatT ~= 0 and was therefore treated
// by every heat guard below exactly like a 20° day: full sky shading,
// no band weighting. A 28° tab came out the same grey as a 17° one.
// Starting the ramp at Warm makes the guards engage across the whole
// hot half, which is where they were always meant to apply.
const heatFloor = isPetsProfile ? 25 : 24; // Warm threshold
const heatMid = isPetsProfile ? 40 : 32; // Extreme threshold
const heatTop = isPetsProfile ? 52 : 41; // Danger threshold
// The spike filter that protects the tab's BAND COLOUR should not
// also decide how hot the tab is allowed to LOOK. sustainedHigh()
// deliberately throws away the day's top ~10% of hours so a single
// warm hour can't paint a cool day warm — right for choosing the hue,
// wrong for choosing vividness. On a muggy UK heat day it discounts
// the part of the day people actually remember: 26 Aug 2026 at Path
// Hill printed 33° but coloured from 29°, which halved heatT and let
// 87% cloud grey out a genuinely hot afternoon.
// So the hue keeps riding on the spike-filtered colourHi, while
// vividness and cloud-resistance ride on the midpoint between that
// and the true peak: the day still has to have been broadly hot, but
// a real hot spell is no longer discounted away to nothing.
const heatDrive = colourHi === null ? null : (colourHi + dayHi) / 2;
const heatT = heatDrive === null ? 0 : clamp01((heatDrive - heatFloor) / (heatMid - heatFloor));
const overT = heatDrive === null ? 0 : clamp01((heatDrive - heatMid) / (heatTop - heatMid));
// Cold counterpart to heatT, anchored on the same two bands at the
// other end of the scale: it starts at the Very cold threshold and
// reaches full strength at Arctic. Used only for skyCap below — the
// pastel ramp stays keyed to heat.
// Two segments, mirroring heatFloor/heatMid/heatTop on the hot end.
// This used to be a single ramp that finished at the ARCTIC
// threshold, which meant coldT pinned to 1 from -10° downward and
// every day below it — the whole of Arctic AND the whole of Extreme
// cold — was drawn identically. Invercargill at -12° and at -28°
// came out the same tab. The hot end never had that problem because
// it ramps across Warm → Caution → Extreme; the cold end now does
// the same, with underT carrying the deep half.
const coldFloor = isPetsProfile ? -3 : 5; // Very cold threshold
const coldMid = isPetsProfile ? -18 : -10; // Arctic threshold
const coldBottom = isPetsProfile ? -28 : -20; // Extreme cold threshold
const coldT = colourHi === null ? 0 : clamp01((coldFloor - colourHi) / (coldFloor - coldMid));
const underT = colourHi === null ? 0 : clamp01((coldMid - colourHi) / (coldMid - coldBottom));
// How far a day sits from the benign middle of the scale, in either
// direction. Chilly/Cool/Comfortable/Warm days are ordinary weather
// and let the sky do the talking; the further a day pushes toward
// either extreme, the more the temperature holds the colour.
const extremityT = Math.max(heatT, coldT);
// Extreme band gets a less pastelised gradient so it reads as a
// clear step between Caution and the pulsing Danger tier.
const isExtreme = tBand.label === 'Extreme';
@@ -341,32 +646,93 @@ export function DayTabs({
// — no rain/snow/cloud tinting, and no weather icon.
const tempOnly = isHomeOrOffice || isVehicleProfile;
// At gale force and above on a dry day, the wind IS the day's weather
// — a sun glyph with a wind badge tucked in the corner undersells a
// 41 mph gale on a bright, dry Monday. So the wind glyph takes the
// main icon slot instead, and the badge is only used for the tier
// below (Near Gale) or when rain already owns the main slot.
const windIsHeadline = !tempOnly && windTier >= 3 && !showPrecip;
// How far the temperature colour is allowed to be shaded by the sky.
// Both ends of the scale are a safety signal, so an Extreme-heat or
// Arctic day keeps its colour almost intact however thick the cloud —
// the sky can only really take the tab on the ordinary days in
// between. Danger is immovable.
// Heat gets a second, separate reduction on top of that. Heat and a
// wet sky are not opposites — a muggy 28° afternoon under rain is
// still a hot day, and the story is the heat. This used to be applied
// to CLOUD only (as cloudHeatCut), which left rain free to repaint a
// hot day blue at up to 0.92 strength; and because reducing the rain
// weight hands the leftover influence straight to cloud, cutting one
// without the other just swaps blue for grey. Cutting skyCap itself
// cuts both together, and keeps the rain→cloud handover coherent.
// Raised to a fractional power so the cut BITES IN LOW CAUTION rather
// than waiting for Extreme. A straight linear cut left a 28° day at
// ~0.43 sky influence — enough rain-blue to still read as a cold wet
// day — because the two caps multiply and each is gentle on its own.
// The rain/snow GLYPH already tells you the day is wet; past the
// middle of the hot half the colour's job is to say how hot it is.
const skyHeatCut = 1 - 0.92 * Math.pow(heatT, 0.75);
const skyCap = tBand.solid ? 0 : (1 - 0.80 * extremityT) * skyHeatCut;
let rgb;
if (tempOnly) {
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.
const heatFactor = isPetsProfile
? (colourHi >= 49 ? 0.15 : colourHi >= 43 ? 0.30 : 0.45)
: (colourHi >= 39 ? 0.15 : colourHi >= 34 ? 0.30 : 0.45);
const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor;
rgb = mixRgb(tBandRgb, [170, 162, 152], t);
} else if (daySnow >= 0.1) {
// Snow: pale → icy blue with depth.
rgb = snowTint(daySnow);
} else if (showPrecip) {
// Rain: light → deep blue with amount (pure blue, no temperature hue).
rgb = rainTint(dayPrecip);
} else if (avgCloud >= 30) {
// Cloud: light → dark grey with cover (pure grey, no temperature hue).
rgb = cloudTint(avgCloud);
} else if (colourHi !== null && colourHi < (isPetsProfile ? 2 : 10)) {
// Clear & cold: keep the cold temperature blue.
// Home/indoor/vehicle tabs show a modelled temperature, not the
// outdoor sky — no weather shading at all.
rgb = tBandRgb;
} else {
// Clear & mild/warm: a plain sunny yellow.
rgb = hexToRgb('#f8e554');
rgb = tBandRgb;
// 2. Cloud — dulls the temperature colour in place (cloudDim:
// same hue, less vivid, slightly darker). Applied BEFORE rain,
// because cloud is the sky condition every day has some of, so
// it is the general case that rain is then layered onto.
// avgCloud comes from the peak hours (colourRows), so a wet
// overcast morning no longer greys out a clear hot afternoon.
let cloudW = 0;
if (avgCloud >= 30) {
cloudW = decisive((avgCloud - 30) / 70) * 0.85 * skyCap;
rgb = cloudDim(rgb, cloudW);
} else if (colourHi !== null) {
// Clear skies: a small warm lift so a sunny day of a given
// temperature reads brighter than an overcast one at the same
// temperature. Deliberately gentle — it tops out at 0.16, so it
// tints the band colour rather than replacing it the way the old
// flat "sunny yellow" branch did.
const sunW = clamp01((30 - avgCloud) / 30) * 0.16 * skyCap;
rgb = overlayTint(rgb, hexToRgb('#f8e554'), sunW);
}
// 3. Rain / snow — last, and only with the influence cloud did not
// already claim, so the two hand over smoothly instead of
// fighting. Sits behind the same 50% gate the icon uses, so the
// tab can never be painted wet while showing a dry sky glyph.
// Amount, confidence and tint colour all come from the peak
// hours (colourRows), so rain that has already cleared by the
// time the day gets hot no longer colours the tab. The showRain
// / showSnow gates still read the whole day, so a wet-morning /
// hot-afternoon day keeps its rain glyph over a warm tab.
// It has to be cloudy for it to rain, so by the time rain is
// applied the tab has ALREADY been taken to grey by step 2 — and
// grey is the one starting point from which deep rain-blue is a
// clean path. That is why rain is no longer throttled by
// (1 - cloudW): the previous version handed cloud the influence
// first and then left rain the scraps, which on a heavily overcast
// day meant rain could barely be seen at all. Rain now tints the
// grey at full strength for its amount and confidence.
//
// The ceiling drops from 0.92 to 0.60 to match: 0.92 was sized for
// pulling a saturated BAND colour toward blue, but pulling an
// already-grey tab that far just produces a flat saturated blue.
// 0.60 lands on a deep blue-grey — a wet overcast day — which is
// what the tab should say.
if (showSnow) {
rgb = overlayTint(rgb, snowTint(peakSnow),
decisive(peakSnow / 1.2) * 0.60 * skyCap);
} else if (showRain && peakWet > 0) {
rgb = overlayTint(rgb, rainTint(peakPrecip),
decisive(peakPrecip / 4) * peakWet * 0.60 * skyCap);
}
}
// Extreme tabs lean toward Danger's crimson as the daily high climbs
// through the band (32→41°C human, 40→52°C pet) — so a day peaking
@@ -386,22 +752,106 @@ export function DayTabs({
// 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 = colourHi === null ? 0 : clamp01((colourHi - heatFloor) / (heatMid - heatFloor));
const overT = colourHi === null ? 0 : clamp01((colourHi - 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
);
//
// The cold end goes TWO-TONE instead of just getting darker. Cold
// band colours are pale to begin with, so deepening them uniformly
// only makes a murky blue-grey — and a hard winter's day should look
// unmistakably different from a mild one, not slightly duller. So the
// two ends of the gradient are pulled apart as the day gets colder:
// the rim deepens toward the true band colour while the core lifts
// toward frost-white, giving a frosted, iced-over tab. Same mechanism
// Danger already uses (dark edge, light centre) at the opposite end
// of the scale. decisive() delays the onset so an ordinary 3°C
// morning stays plain and only a genuinely cold day frosts over.
// The frost lift on the CENTRE was +0.22, which put the core at 0.91
// — effectively pure white (#f3f9fd). The band colour survived only
// as a thin rim, so the coldest, most dangerous days rendered as the
// faintest tabs on the strip, inverting the safety hierarchy the hot
// end enforces. Pulled back to +0.14 so the core still reads as iced
// over but the tab keeps its body; underT then takes the deep half
// DOWN again, so Extreme cold is the most saturated cold tab rather
// than the most washed-out.
const frostT = decisive(coldT);
// Band WEIGHT — how much of the band colour is left standing rather
// than washed out toward white. The two numbers below are "how far
// this stop blends toward white", so weighting a tier up means
// scaling BOTH stops down together. Scaling only the core is what the
// first attempt did, and it pulled the centre down onto the rim until
// the two met — which flattened the radial into a slab of flat colour
// and lost the lit-from-within look the whole tab strip is built on.
// Scaling both keeps the centre-to-rim distance intact and simply
// moves the whole gradient deeper into the band colour.
//
// Stepped by tier, because the tiers are the safety story and have to
// escalate visibly rather than read as three similar oranges.
// Danger is deliberately excluded: it already has its own treatment
// (flat crimson, dark rim, light core, outlined text) and weighting
// it as well buried its hi/lo numbers.
const tierFrac = tBand.solid ? 0
: tBand.label === 'Extreme' ? 0.75 // 75% more weight
: tBand.label === 'Caution' ? 0.50 // 50% more weight
: 0;
// Eased in across Caution so 27° is a smooth departure from an
// ordinary day rather than a hard step. Extreme is already past the
// top of that ramp, so it takes its weight flat.
const ramp = tBand.label === 'Caution' ? heatT : 1;
// Deepen BOTH stops by the same amount — never scale them. Scaling
// preserves their ratio but crushes the DISTANCE between them, which
// is what the eye actually reads as the lit-from-within core: at full
// Caution weight it closed the 0.41 core-to-rim gap to 0.24 and the
// radial flattened into a slab of colour. Subtracting a constant
// moves the whole gradient deeper into the band while leaving that
// distance untouched. The offset is a fraction of the rim's own
// headroom, so neither stop can be pushed past the pure band colour.
//
// But the offset is a RIM treatment — its whole job is to push the
// outer edge further into the band colour. Taking the full offset off
// the centre too dragged the core down in lockstep, so the weighted
// tiers (Caution and Extreme — the only ones with a non-zero offset)
// were exactly the tabs that lost their white core: the hotter the
// day, the more of the lit-from-within look it gave away. The centre
// now takes only part of the offset.
//
// Note this WIDENS the core-to-rim distance rather than closing it,
// which is the safe direction — the failure mode the note above warns
// about is the centre collapsing onto the rim, not lifting away from
// it. Lifting is the effect that reads as "lit from within".
const CENTRE_DEEPEN = 0.55;
const deepen = (edge, centre) => {
const off = clamp01(edge) * tierFrac * ramp;
return [clamp01(edge - off), clamp01(centre - off * CENTRE_DEEPEN)];
};
// Minimum core-to-rim distance — the "glow" floor.
//
// Deepening only ever fires on Caution/Extreme, and frostT only at
// the cold end, which leaves the entire middle of the scale — Cool,
// Comfortable, Warm — with its two stops barely 0.19 apart. At that
// distance the radial is invisible, so those tabs read as flat slabs
// of pale colour sitting next to hot ones that visibly glow.
//
// The middle tabs are not actually less white in the centre — 0.69
// beats Extreme's 0.59. They have no CONTRAST to make that whiteness
// legible. So the fix is distance, not lightness: push the two stops
// apart around their own midpoint, which lifts the core and deepens
// the rim together and leaves the tab's overall lightness where it
// was. Tabs already clearing the floor are returned untouched, so the
// hot and frosted ends keep exactly the treatment they have.
const MIN_GLOW = 0.36;
const glow = ([edge, centre]) => {
if (centre - edge >= MIN_GLOW) return [edge, centre];
const mid = (edge + centre) / 2;
return [clamp01(mid - MIN_GLOW / 2), clamp01(mid + MIN_GLOW / 2)];
};
const [edgeNeutral, centreNeutral] = glow(deepen(
0.50 - 0.30 * heatT - 0.05 * overT - 0.34 * frostT - 0.10 * underT, // edge: 0.50 → 0.20 → 0.15 · cold → 0.16 → 0.06
0.69 - 0.03 * heatT + 0.06 * overT + 0.14 * frostT - 0.06 * underT, // centre: 0.69 → 0.66 → 0.72 · cold → 0.83 → 0.77
));
const [edgeActive, centreActive] = glow(deepen(
0.42 - 0.26 * heatT - 0.04 * overT - 0.30 * frostT - 0.06 * underT, // edge: 0.42 → 0.16 → 0.12 · cold → 0.12 → 0.06
0.64 + 0.02 * heatT + 0.06 * overT + 0.18 * frostT - 0.06 * underT, // centre: 0.64 → 0.66 → 0.72 · cold → 0.82 → 0.76
));
const wBgNeutral = weatherGradientNeutral(wR, wG, wB, edgeNeutral, centreNeutral);
const wBgActive = weatherGradientActive(wR, wG, wB, edgeActive, centreActive);
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)})`;
@@ -422,9 +872,10 @@ export function DayTabs({
setProPromptDay(null);
}
}}
title=${locked
title=${(locked
? `${band.label} · SunScope Extra unlocks day ${i + 1}`
: `${band.label} · day ${i + 1} of 14`}
: `${band.label} · day ${i + 1} of 14`)
+ (windTier >= 2 ? ` · ${windLabel} — gusts to ${maxGust} mph` : '')}
style=${{
background: isActive ? wBgActive : wBgNeutral,
color: wText,
@@ -447,6 +898,7 @@ export function DayTabs({
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
</span>
<span style=${{ display: 'block', margin: '6px auto 0px', lineHeight: 1 }}>
<span class="utci-day-icon">
${tempOnly
? (isVehicleProfile
? (vehicleHazard === 'snow' ? html`<${PrecipIcon} precip=${0} snow=${hazSnow} size=${30} />`
@@ -456,8 +908,23 @@ export function DayTabs({
: html`<${CarIcon} size=${30} />`)
: html`<${HouseIcon} size=${30} />`)
: showPrecip
? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${30} />`
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`}
? html`<${PrecipIcon} precip=${showRain ? dayPrecip : 0} snow=${daySnow}
drops=${rainDrops} size=${30} />`
: windIsHeadline
? html`<${WindIcon} size=${30} tier=${windTier} />`
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`}
${/* Wind that is real but isn't the day's headline (Near Gale, or
a gale on a day rain already speaks for) rides ON TOP of the
sky glyph rather than sitting off in the tab corner, so the
icon states one combined condition — "windy AND wet" — the
way the eye reads it anyway. The halo keeps the gust lines
legible over the cloud beneath. */''}
${windTier >= 2 && !windIsHeadline && html`
<span class="utci-day-wind"
aria-label=${`${windLabel}, gusts to ${maxGust} mph`}>
<${WindIcon} size=${windBadgeSize} tier=${windTier} halo />
</span>`}
</span>
</span>
${dayHi !== null && html`
<span style=${{