// ------------------------------------------------------------------------ // components/DayTabs.js - Day-tab strip, pro-prompt card and confidence // band bar for the UTCIForecast app. // // Extracted from app.js to keep the main component under the AI edit // safe-zone. All state lives in useAppState - this component is purely // presentational and receives everything it needs as props. // // The profile picker and config strip that used to live here now sit in the // ConfigPanel nav dropdown; DayTabs keeps the derived "main" metric needed // to compute the day-tab hi/lo numbers, and renders a read-only row above // the day tabs stating the active profile + thermal basis so it's always // clear what temperatures are being shown (click/Enter opens the dropdown). // // Props: // days - array of day objects from buildHourlyRows // selectedDay/setSelectedDay - active day tab index + setter // isPro - boolean Pro status // openRestore - opens the "Already subscribed?" restore modal // proPromptDay/setProPromptDay - locked-day upsell index + setter // proPromptSource/setProPromptSource - upsell copy key + setter // activeProfile - current filter profile key // outdoorsVariant - current outdoors sub-variant key // openPanel - opens the profile/config nav dropdown // vehicleType - current vehicle config, for the "Viewing" row // label // buildingType/indoorManaged/utciEnv/furColor - current config values, // for the "Viewing" row label // dayTabsRef - ref for the scrollable tab strip element // canScrollLeft/canScrollRight - booleans for the fade chevrons // scrollDayTabs - function(dir) to scroll strip left/right // ------------------------------------------------------------------------ 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, 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, WindIcon } from '../components.js'; import { SubscribeModal } from './SubscribeModal.js'; const html = htm.bind(h); // Per-selection gradients for the active-profile-row background — one entry // per possible value of each thermal-model category, so the row's shade // reflects not just "which model" (SunSoak/Vehicle/Indoor/Pets) but the // specific option currently picked within it (e.g. Beach vs Forest), the // same way the profile cards use a distinct "scene" per preset. const ROW_GRADIENTS = { solar: { open: 'linear-gradient(120deg, rgba(150,200,120,0.55), rgba(222,236,196,0.30))', urban: 'linear-gradient(120deg, rgba(160,168,178,0.55), rgba(218,222,228,0.30))', beach: 'linear-gradient(120deg, rgba(240,208,140,0.55), rgba(182,220,236,0.30))', river: 'linear-gradient(120deg, rgba(120,196,182,0.55), rgba(202,236,228,0.30))', forest: 'linear-gradient(120deg, rgba(66,124,70,0.55), rgba(170,202,156,0.30))', openwater: 'linear-gradient(120deg, rgba(96,150,204,0.55), rgba(180,214,238,0.30))', alpine: 'linear-gradient(120deg, rgba(180,210,230,0.55), rgba(238,246,250,0.30))', desert: 'linear-gradient(120deg, rgba(220,164,96,0.55), rgba(246,220,172,0.30))', }, vehicle: { car: 'linear-gradient(120deg, rgba(180,192,202,0.55), rgba(222,228,234,0.30))', mpv: 'linear-gradient(120deg, rgba(172,186,200,0.55), rgba(216,224,232,0.30))', suv: 'linear-gradient(120deg, rgba(154,172,190,0.55), rgba(206,216,228,0.30))', truck: 'linear-gradient(120deg, rgba(136,158,180,0.55), rgba(196,208,222,0.30))', motorhome: 'linear-gradient(120deg, rgba(200,180,148,0.55), rgba(232,220,196,0.30))', caravan: 'linear-gradient(120deg, rgba(208,188,158,0.55), rgba(236,224,204,0.30))', }, indoor: { brick: 'linear-gradient(120deg, rgba(190,120,92,0.50), rgba(226,178,158,0.28))', modern: 'linear-gradient(120deg, rgba(162,172,180,0.50), rgba(212,218,224,0.28))', victorian: 'linear-gradient(120deg, rgba(160,76,54,0.50), rgba(210,142,122,0.28))', stone: 'linear-gradient(120deg, rgba(146,138,124,0.50), rgba(202,196,184,0.28))', timber: 'linear-gradient(120deg, rgba(182,142,92,0.50), rgba(222,192,150,0.28))', flat: 'linear-gradient(120deg, rgba(162,162,168,0.50), rgba(212,212,218,0.28))', conservatory: 'linear-gradient(120deg, rgba(140,198,220,0.50), rgba(202,232,242,0.28))', office: 'linear-gradient(120deg, rgba(122,148,184,0.50), rgba(188,204,226,0.28))', }, fur: { black: 'linear-gradient(120deg, rgba(52,48,44,0.55), rgba(112,106,100,0.30))', brown: 'linear-gradient(120deg, rgba(126,84,48,0.55), rgba(178,140,98,0.30))', golden: 'linear-gradient(120deg, rgba(208,160,80,0.55),rgba(236,204,144,0.30))', white: 'linear-gradient(120deg, rgba(222,214,198,0.55),rgba(248,246,238,0.30))', }, }; // Active-tab variant: a more solid (less pastelised) version of the weather // colour, drawn as a radial gradient whose strong colour sits at the outer // edge and softens toward a lighter centre — so the hue reads as radiating // inward from the outside of the tab. // edgeBlend pastelises the outer edge (higher = lighter). centerBlend, when // supplied, blends the centre straight from the raw colour for a lighter core // (used by the Danger tier to keep a dark edge but a light centre); otherwise // the centre is just a lightened version of the edge (original behaviour). function weatherGradientNeutral(r, g, b, edgeBlend = 0.50, 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)); const [er, eg, eb] = [blend(r, edgeBlend), blend(g, edgeBlend), blend(b, edgeBlend)]; const [cr, cg, cb] = centerBlend != null ? [blend(r, centerBlend), blend(g, centerBlend), blend(b, centerBlend)] : [lighten(er), lighten(eg), lighten(eb)]; const center = `rgb(${cr},${cg},${cb})`; const edge = `rgb(${er},${eg},${eb})`; 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)]; } // 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)); const [er, eg, eb] = [blend(r, edgeBlend), blend(g, edgeBlend), blend(b, edgeBlend)]; const [cr, cg, cb] = centerBlend != null ? [blend(r, centerBlend), blend(g, centerBlend), blend(b, centerBlend)] : [lighten(er), lighten(eg), lighten(eb)]; const center = `rgb(${cr},${cg},${cb})`; const edge = `rgb(${er},${eg},${eb})`; return `radial-gradient(circle at 50% 50%, ${center} 0%, ${center} 28%, ${edge} 100%)`; } export function DayTabs({ days, selectedDay, setSelectedDay, isPro, openRestore, proPromptDay, setProPromptDay, proPromptSource, setProPromptSource, activeProfile, outdoorsVariant, openPanel, vehicleType, buildingType, indoorManaged, utciEnv, furColor, dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs, }) { // Which metric drives the day-tab's hi/lo readout depends on the active // profile — default is SunSoak (felt) + Shade, but profiles with their own // dedicated calc show their own numbers instead. The profile picker and the // full config strip now live in the nav dropdown (ConfigPanel); here we // only need the derived main metric to compute the day-tab numbers and to // label the "what temps am I looking at" row below. const { isHomeOrOffice, isVehicleProfile, isPetsProfile, mainConfigKey, mainField, secondaryField, mainLabel, mainMetricLabel, mainMetricDesc, secondaryMetricLabel, secondaryMetricDesc, } = deriveProfileMain(activeProfile, outdoorsVariant); // Read-only label of the current main-config value, for the row above the // day tabs — makes it obvious at a glance which temperature basis (SunSoak // environment, vehicle type, building type, fur colour) the numbers below // are computed from. const mainConfigValue = mainConfigKey === 'vehicle' ? (VEHICLE_TYPES[vehicleType]?.name || '') : mainConfigKey === 'indoor' ? ((BUILDING_TYPES[buildingType]?.name || '') + (indoorManaged ? ' · Managed' : '')) : mainConfigKey === 'fur' ? (FUR_COLORS[furColor]?.name || '') : (UTCI_ENVIRONMENTS[utciEnv]?.label || ''); // Tints the row background to match the thermal model driving it, reusing // the same colour groups the CustomSelect pickers use (e.g. SunSoak's // "Solar model" dropdown is grp-felt) so the row reads as an extension of // those controls rather than a separate, unrelated style. const rowGrpClass = mainConfigKey === 'vehicle' ? 'grp-wind' : mainConfigKey === 'indoor' ? 'grp-ambient' : mainConfigKey === 'fur' ? 'grp-pets' : 'grp-felt'; // Within that group, shade further by the exact option chosen — same idea // as the "Solar model" pulldown, but keyed to whichever value is actually // driving the numbers right now (environment / vehicle / building / fur). const rowOptionKey = mainConfigKey === 'vehicle' ? vehicleType : mainConfigKey === 'indoor' ? buildingType : mainConfigKey === 'fur' ? furColor : 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}>
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); } }} style=${rowGradient ? { '--row-bg': rowGradient } : undefined} title="Change profile & settings"> Profile ${activeProfile === 'outdoors' ? (variantIcons[outdoorsVariant] || '🌤️') : (FILTER_PROFILES[activeProfile]?.icon || '🎯')} ${mainLabel} ${mainConfigValue && html` Viewing ${mainConfigValue} `}
${/* Fixed label column outside the scroller — names every stacked row in each tab (day name, date, icon, then the two numbers, since which metrics those are changes with the active profile). Mirrors the tabs' own top-down layout row for row (see .utci-day-legend-col in table.css). */''}
Day Date ${isHomeOrOffice ? 'Home' : isVehicleProfile ? 'Alert' : 'Sky'}
${mainMetricLabel} ${secondaryField && html` ${secondaryMetricLabel}`}
${days.map((d, i) => { const band = confidenceBand(i); const locked = !isPro && i >= FREE_DAYS; const isActive = i === selectedDay; const dDate = new Date(d.key + 'T00:00Z'); const dayName = i === 0 ? 'Today' : i === 1 ? 'Tom' : dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }); const mainVals = d.rows.map(r => r[mainField]).filter(v => isFinite(v)); 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 // 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; // 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(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 // Vehicle/Driver so the outside-vs-cabin gap is visible. let shadeHi = null, shadeLo = null; if (secondaryField) { const secondaryVals = d.rows.map(r => r[secondaryField]).filter(v => isFinite(v)); shadeHi = secondaryVals.length ? Math.round(Math.max(...secondaryVals)) : null; shadeLo = secondaryVals.length ? Math.round(Math.min(...secondaryVals)) : null; } const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0); const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0); const catCounts = {}; repRows.forEach(r => { if (r.cloudCat) catCounts[r.cloudCat] = (catCounts[r.cloudCat] || 0) + 1; }); const modalCloudCat = Object.keys(catCounts).sort((a, b) => catCounts[b] - catCounts[a])[0] || 'clear'; // Use solar-noon row (highest elevation) as the representative row 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; // ---- 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 // daylight repRows) since fog and frost often hit overnight or on // an early commute, then picks the worst hazard by priority. let vehicleHazard = null; let hazPrecip = 0, hazSnow = 0; if (isVehicleProfile) { const allSnow = d.rows.reduce((s, r) => s + (r.snow || 0), 0); const allPrecip = d.rows.reduce((s, r) => s + (r.precip || 0), 0); const visVals = d.rows.map(r => r.visKm).filter(v => isFinite(v)); const minVis = visVals.length ? Math.min(...visVals) : null; const taVals = d.rows.map(r => r.Ta).filter(v => isFinite(v)); const minTa = taVals.length ? Math.min(...taVals) : null; if (allSnow >= 0.1) { vehicleHazard = 'snow'; hazSnow = allSnow; } else if (minTa !== null && minTa <= 0 && allPrecip < 0.3) { vehicleHazard = 'ice'; } else if (minVis !== null && minVis < 2) { vehicleHazard = 'fog'; } else if (allPrecip >= 0.3) { vehicleHazard = 'rain'; hazPrecip = allPrecip; } } // 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' }; // 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(colourHi, isPetsProfile ? DAY_TAB_PET_BANDS : DAY_TAB_UTCI_BANDS); // 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; // 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'; // Home/indoor/vehicle tabs show a modelled temperature, not the // outdoor sky, so the tab shade should track that temperature only // — 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) { // Home/indoor/vehicle tabs show a modelled temperature, not the // outdoor sky — no weather shading at all. rgb = tBandRgb; } else { 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 // near 40° visibly edges into danger-red without becoming Danger. if (isExtreme) { const floor = isPetsProfile ? 40 : 32; const ceil = isPetsProfile ? 52 : 41; 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; // 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. // // 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)})`; // Extreme tabs get a strong red border so the tier pops even when // the tab is not selected — a firmer warning than the tan default. const wBorder = isExtreme ? '#c22f1a' : '#c9b08a'; return html` `; })}
${proPromptDay !== null && days[proPromptDay] && (() => { const promptDate = new Date(days[proPromptDay].key + 'T00:00Z'); const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' }); const extraPromptCopy = { // Profile buttons 'profile:alltemps': { title: 'Temps is part of SunScope Extra', detail: 'Extra unlocks the temperature comparison view — air, soil, concrete, vehicle interior, and indoor estimates side by side in one place.', }, 'profile:showall': { title: 'Show All is part of SunScope Extra', detail: 'Extra unlocks the full column set — every data point SunScope tracks, visible at once for deep-dive analysis.', }, 'profile:custom': { title: 'Custom columns are part of SunScope Extra', detail: 'Extra lets you hand-pick exactly which columns appear. Mix and match air, dew, soil, UV, UTCI and more to build your perfect view.', }, // Edit columns button on the forecast toolbar 'columns': { title: 'Custom columns are part of SunScope Extra', detail: 'Extra lets you hand-pick exactly which columns appear. Mix and match air, dew, soil, UV, UTCI and more to build your perfect view.', }, // Places 'variant:construction': { title: 'Construction is part of SunScope Extra', detail: 'Extra adds the Construction profile with felt temperature, wind, rain probability, visibility, and air quality — the key factors for safe site working.', }, // Activities 'variant:hiking': { title: 'Hiking is part of SunScope Extra', detail: 'Extra adds the Hiking profile with felt temperature, UV index, burn time, wind direction, and visibility — essential for longer days on exposed terrain.', }, 'variant:photography': { title: 'Photography is part of SunScope Extra', detail: 'Extra adds the Photography profile with direct and diffuse radiation, sun elevation, cloud cover, and visibility — the conditions that make or break a shoot.', }, 'variant:sailing': { title: 'Sailing is part of SunScope Extra', detail: 'Extra adds the Sailing profile with wind speed and direction, dew point, UV, burn time, sun elevation, and visibility for on-water planning.', }, 'variant:wintersports': { title: 'Winter Sports is part of SunScope Extra', detail: 'Extra adds the Winter Sports profile with UV-A and UV-B, burn time, sun elevation, wind direction, and visibility for snow and slope conditions.', }, 'variant:naturist': { title: 'Naturist is part of SunScope Extra', detail: 'Extra adds the Naturist profile with full skin-exposure detail — UV, burn time, felt temperature, dew point, humidity, and air quality.', }, // Work 'variant:market': { title: 'Market Trading is part of SunScope Extra', detail: 'Extra adds the Market Trading profile with felt temperature, wind direction, rain probability, and visibility — the key factors for planning stall days.', }, 'variant:windowcleaning': { title: 'Window Cleaning is part of SunScope Extra', detail: 'Extra adds the Window Cleaning profile focused on wind speed and direction, rain, and felt temperature — the conditions that determine whether work is safe and worthwhile.', }, 'variant:office': { title: 'Office is part of SunScope Extra', detail: 'Extra adds the Office profile with solar gain, managed indoor temperature, humidity, air quality, and rain probability — useful for commute planning and building comfort.', }, 'variant:driver': { title: 'Driver / Trucker is part of SunScope Extra', detail: 'Extra adds the Driver / Trucker profile with vehicle interior temperature, UV, wind speed and direction, rain probability, and visibility — the conditions that matter for long hours on the road.', }, 'export': { title: 'Export day data is part of SunScope Extra', detail: 'Extra lets you download a full hourly spreadsheet for any day — all 41 columns including felt temperature, UV, soil, wind, and air quality, styled and ready to use in Excel or LibreOffice.', }, }; // Day-tab locks get the day-specific heading; any other locked feature // without its own entry above falls back to a feature-agnostic message. const fallbackCopy = proPromptSource === 'day' ? { title: `${dayName}'s forecast is part of SunScope Extra`, detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.', } : { title: 'This is part of SunScope Extra', detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.', }; const promptCopy = extraPromptCopy[proPromptSource] || fallbackCopy; return html` <${SubscribeModal} title=${promptCopy.title} detail=${promptCopy.detail} onClose=${() => setProPromptDay(null)} openRestore=${openRestore} />`; })()} `; }