diff --git a/assets/js/components/DayTabs.js b/assets/js/components/DayTabs.js index 65cebbe..4958a4a 100644 --- a/assets/js/components/DayTabs.js +++ b/assets/js/components/DayTabs.js @@ -102,6 +102,18 @@ function weatherGradientNeutral(r, g, b, edgeBlend = 0.50, centerBlend = null) { return `radial-gradient(circle at 50% 50%, ${center} 0%, ${center} 28%, ${edge} 100%)`; } +// Colour-driving "sustained high": the day's high with brief spikes ignored, so a +// single hot hour on an otherwise cool, damp day doesn't paint the whole tab warm. +// Drops the top ~10% of hours (always at least the single hottest) and takes the next +// value down — a temperature that actually persisted. The PRINTED hi number and the +// Danger alarm still key off the true peak, not this. +function sustainedHigh(vals) { + if (!vals.length) return null; + const sorted = [...vals].sort((a, b) => b - a); + const drop = Math.max(1, Math.round(sorted.length * 0.1)); + return sorted[Math.min(drop, sorted.length - 1)]; +} + 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)); @@ -232,6 +244,17 @@ export function DayTabs({ const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null; const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null; + // 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 + // the true peak — and colourHi collapses back to dayHi there, so a real + // Danger day still gets its full colouring. The printed hi/lo numbers below + // always show the true dayHi/dayLo. + const catFn = isPetsProfile ? petCategory : utciCategory; + const isDanger = dayHi !== null && catFn(dayHi).solid === true; + const colourHi = dayHi === null ? null + : (isDanger ? dayHi : Math.round(sustainedHigh(mainVals))); + // Secondary row under each hi/lo: Shade for SunSoak, Managed for // indoor, Pet Shade for pets, and plain air temperature for // Vehicle/Driver so the outside-vs-cabin gap is visible. @@ -290,8 +313,7 @@ export function DayTabs({ // • Otherwise rain wins, then cloud, then a clear "sunny"/cold colour. // Shade ramps live in utils.js (WEATHER TINT PALETTE) so the warning // event banner can colour itself from exactly the same source. - const catFn = isPetsProfile ? petCategory : utciCategory; - const tBand = dayHi !== null ? catFn(dayHi) : { bg: '#f8e554' }; + const tBand = colourHi !== null ? catFn(colourHi) : { 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 @@ -299,7 +321,7 @@ export function DayTabs({ // alarm colour, not a point on the gradient. const tBandRgb = tBand.solid ? hexToRgb(tBand.bg) - : bandRampRgb(dayHi, isPetsProfile ? PET_BANDS : UTCI_BANDS); + : bandRampRgb(colourHi, 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; @@ -309,8 +331,7 @@ export function DayTabs({ // 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 = dayHi !== null && dayHi >= (isPetsProfile ? 35 : 27); // Hot band and above - const isDanger = tBand.solid === true; + const isHot = colourHi !== null && colourHi >= (isPetsProfile ? 35 : 27); // Hot band and above // 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'; @@ -327,8 +348,8 @@ export function DayTabs({ // Keep the heat hue; cloud only mutes it a touch (orange/red never // greens) and rain does not override heat. const heatFactor = isPetsProfile - ? (dayHi >= 49 ? 0.15 : dayHi >= 43 ? 0.30 : 0.45) - : (dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45); + ? (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) { @@ -340,7 +361,7 @@ export function DayTabs({ } else if (avgCloud >= 30) { // Cloud: light → dark grey with cover (pure grey, no temperature hue). rgb = cloudTint(avgCloud); - } else if (dayHi !== null && dayHi < (isPetsProfile ? 2 : 10)) { + } else if (colourHi !== null && colourHi < (isPetsProfile ? 2 : 10)) { // Clear & cold: keep the cold temperature blue. rgb = tBandRgb; } else { @@ -353,7 +374,7 @@ export function DayTabs({ if (isExtreme) { const floor = isPetsProfile ? 40 : 32; const ceil = isPetsProfile ? 52 : 41; - const t = Math.max(0, Math.min(1, (dayHi - floor) / (ceil - floor))); + const t = Math.max(0, Math.min(1, (colourHi - floor) / (ceil - floor))); rgb = mixRgb(rgb, [136, 0, 0], t * 0.55); } const [wR, wG, wB] = rgb; @@ -369,8 +390,8 @@ export function DayTabs({ 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 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