Files
fraxle 71860b9dd9 5.4.0
Restructure
New Journal entries
Colour tabs update with wind
2026-08-31 15:12:45 +01:00

1055 lines
63 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ------------------------------------------------------------------------
// 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}>
<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">
<span class="active-profile-row-item">
<span class="active-profile-row-label">Profile</span>
<span class="active-profile-row-value">
${activeProfile === 'outdoors'
? (variantIcons[outdoorsVariant] || '🌤️')
: (FILTER_PROFILES[activeProfile]?.icon || '🎯')}
${mainLabel}
</span>
</span>
${mainConfigValue && html`
<span class="active-profile-row-sep" aria-hidden="true">·</span>
<span class="active-profile-row-item">
<span class="active-profile-row-label">Viewing</span>
<span class="active-profile-row-value">${mainConfigValue}</span>
</span>`}
<span class="active-profile-row-edit" aria-hidden="true">Change ✎</span>
</div>
<div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}>
<button
class=${`utci-day-scroll left${canScrollLeft ? '' : ' hidden'}`}
onClick=${() => scrollDayTabs(-1)}
aria-label="Scroll days left"
type="button"></button>
<button
class=${`utci-day-scroll right${canScrollRight ? '' : ' hidden'}`}
onClick=${() => scrollDayTabs(1)}
aria-label="Scroll days right"
type="button"></button>
${/* 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). */''}
<div class="utci-day-legend-col">
<span class="utci-day-legend-day">Day</span>
<span class="utci-day-legend-date">Date</span>
<span class="utci-day-legend-icon">${isHomeOrOffice ? 'Home' : isVehicleProfile ? 'Alert' : 'Sky'}</span>
<div class="utci-day-legend-values">
<span class="utci-day-legend-main" title=${mainMetricDesc}>${mainMetricLabel}</span>
${secondaryField && html`
<span class="utci-day-legend-sep" aria-hidden="true"></span>
<span class="utci-day-legend-sec" title=${secondaryMetricDesc}>${secondaryMetricLabel}</span>`}
</div>
</div>
<div class="utci-day-tabs" ref=${dayTabsRef}>
${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`
<button
key=${d.key}
class=${`utci-day-tab ${isActive ? 'active' : ''}${locked ? ' locked' : ''}${isDanger ? ' danger' : ''}`}
onClick=${() => {
if (locked) {
setProPromptSource('day');
setProPromptDay(i);
} else {
setSelectedDay(i);
setProPromptDay(null);
}
}}
title=${(locked
? `${band.label} · SunScope Extra unlocks day ${i + 1}`
: `${band.label} · day ${i + 1} of 14`)
+ (windTier >= 2 ? ` · ${windLabel} — gusts to ${maxGust} mph` : '')}
style=${{
background: isActive ? wBgActive : wBgNeutral,
color: wText,
borderTop: `1px solid ${wBorder}`,
borderRight: 'none',
borderBottom: `1px solid ${wBorder}`,
borderLeft: i === 0 ? `1px solid ${wBorder}` : 'none',
opacity: locked ? 0.5 : 1,
cursor: locked ? 'not-allowed' : 'pointer',
position: 'relative',
filter: isActive ? 'saturate(1.1) brightness(1.04)' : 'none',
outline: isActive ? `2px solid ${wOutline}` : 'none',
outlineOffset: isActive ? '-2px' : '0',
}}
>
${locked && html`
<span style=${{ position: 'absolute', top: '3px', right: '5px', fontSize: '10px', opacity: 0.75 }}>🔒</span>`}
<span class="utci-day-name">${dayName}</span>
<span class="utci-day-date" style=${{ color: '#5a3f24' }}>
${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} />`
: vehicleHazard === 'rain' ? html`<${PrecipIcon} precip=${hazPrecip} snow=${0} size=${30} />`
: vehicleHazard === 'fog' ? html`<${FogIcon} size=${30} />`
: vehicleHazard === 'ice' ? html`<${IceIcon} size=${30} />`
: html`<${CarIcon} size=${30} />`)
: html`<${HouseIcon} size=${30} />`)
: showPrecip
? 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=${{
display: 'flex',
alignItems: 'stretch',
justifyContent: 'center',
gap: '6px',
fontFamily: 'Manrope, sans-serif',
fontStyle: 'normal',
letterSpacing: 0,
textTransform: 'none',
textShadow: '0 0 14px rgba(255,255,255,0.85), 0 0 11px rgba(255,255,255,0.7), 0 0 8px rgba(255,255,255,0.6), 0 0 5px rgba(255,255,255,0.5)',
marginTop: '2px',
}}>
<span style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px' }}>
<span style=${{ color: '#c0622a', fontWeight: 800, fontSize: '14px', lineHeight: 1.1 }}>${dayHi}°</span>
${shadeHi !== null && html`
<span style=${{ width: '10px', height: '1px', background: 'rgba(0,0,0,0.15)', margin: '1px 0' }}></span>
<span style=${{ color: '#c0622a', fontWeight: 600, fontSize: '11px', lineHeight: 1.1 }}>${shadeHi}°</span>`}
</span>
<span style=${{ width: '1px', background: 'rgba(0,0,0,0.15)', margin: '2px 0', alignSelf: 'stretch' }}></span>
<span style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px' }}>
<span style=${{ color: '#3a6080', fontWeight: 800, fontSize: '14px', lineHeight: 1.1 }}>${dayLo}°</span>
${shadeLo !== null && html`
<span style=${{ width: '10px', height: '1px', background: 'rgba(0,0,0,0.15)', margin: '1px 0' }}></span>
<span style=${{ color: '#3a6080', fontWeight: 600, fontSize: '11px', lineHeight: 1.1 }}>${shadeLo}°</span>`}
</span>
</span>`}
</button>`;
})}
</div>
</div>
${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}
/>`;
})()}
</${Fragment}>`;
}