Tweaked and updated the day tab background shades depending on cloud & rain. Give comfortable special treatment to avoid the green as it just looks weird.
This commit is contained in:
fraxle
2026-06-27 18:41:56 +01:00
parent df3256462a
commit bf64122bc2
4 changed files with 167 additions and 31 deletions
+3
View File
@@ -466,6 +466,9 @@ export function UTCIForecast() {
skinType, skinType,
visibleCols, visibleCols,
vehicleSpeed, vehicleSpeed,
// Farming uses these for seasonal sow/harvest advice (week's dates + weather).
activeProfile === 'farming' ? days : null,
location?.lat,
); );
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026". // Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
+42 -29
View File
@@ -45,26 +45,13 @@
import { h, Fragment } from '../../vendor/preact.js'; import { h, Fragment } from '../../vendor/preact.js';
import { useRef, useEffect, useState } from '../../vendor/preact-hooks.js'; import { useRef, useEffect, useState } from '../../vendor/preact-hooks.js';
import htm from '../../vendor/htm.js'; import htm from '../../vendor/htm.js';
import { confidenceBand } from '../utils.js'; import { confidenceBand, utciCategory } from '../utils.js';
import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys } from '../config.js'; import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys } from '../config.js';
import { CloudIcon, PrecipIcon, CustomSelect } from '../components.js'; import { CloudIcon, PrecipIcon, CustomSelect } from '../components.js';
import { SubscribeModal } from './SubscribeModal.js'; import { SubscribeModal } from './SubscribeModal.js';
const html = htm.bind(h); const html = htm.bind(h);
// Converts a base RGB colour into the same 135° gradient used by the thermal
// bands legend: 50% white blend to pastelise, then a subtle light→dark sweep.
function weatherGradient(r, g, b) {
const blend = (c) => Math.round(c + (255 - c) * 0.82);
const [mr, mg, mb] = [blend(r), blend(g), blend(b)];
const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.30));
const darken = (c) => Math.round(c * 0.96);
const light = `rgb(${lighten(mr)},${lighten(mg)},${lighten(mb)})`;
const mid = `rgb(${mr},${mg},${mb})`;
const dark = `rgb(${darken(mr)},${darken(mg)},${darken(mb)})`;
return `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`;
}
// Active-tab variant: a more solid (less pastelised) version of the weather // 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 // 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 // edge and softens toward a lighter centre — so the hue reads as radiating
@@ -333,21 +320,47 @@ export function DayTabs({
const repDt = noonRow ? noonRow.dt : dDate; const repDt = noonRow ? noonRow.dt : dDate;
const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1; const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1;
// Base RGB for each weather condition — pastelised by weatherGradient() // Day-tab colour — PRIORITY model, no hue blending.
const [wR, wG, wB] = (daySnow >= 0.1) ? [100, 160, 230] // Mixing a warm yellow with grey/blue passes through green, so rather
: (dayHi > 44) ? [136, 0, 0] // than blend temperature with sky we pick ONE dimension by priority and
: (dayHi > 38) ? [210, 60, 30] // only vary its shade: heat → snow → rain → cloud → sun (first wins).
: showPrecip ? [ 50, 120, 200] // • Hot/extreme days always keep their heat colour (a safety signal).
: (dayHi > 32) ? [220, 110, 30] // • Otherwise rain wins, then cloud, then a clear "sunny"/cold colour.
: (dayHi > 26) ? [220, 180, 30] const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * Math.max(0, Math.min(1, t))));
: modalCloudCat === 'overcast' ? [100, 110, 130] const hex2rgb = (hx) => { const n = parseInt(hx.replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
: (dayHi !== null && dayHi < 9) ? [ 60, 110, 180] const tBand = dayHi !== null ? utciCategory(dayHi) : { bg: '#f8e554' };
: modalCloudCat === 'scattered' ? [160, 150, 130]
: modalCloudCat === 'wispy' ? [200, 180, 130] const ccVals = repRows.map(r => r.cc).filter(v => isFinite(v));
: [220, 190, 50]; const avgCloud = ccVals.length ? ccVals.reduce((s, v) => s + v, 0) / ccVals.length : 0;
// Danger days keep a dark, saturated edge with a lighter centre.
const isDanger = dayHi !== null && dayHi > 44; const isHot = dayHi !== null && dayHi >= 29; // Hot band and above
const wBg = weatherGradient(wR, wG, wB); const isDanger = tBand.solid === true;
let rgb;
if (isHot) {
// Keep the heat hue; cloud only mutes it a touch (orange/red never
// greens) and rain does not override heat.
const heatFactor = dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45;
const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor;
rgb = mix(hex2rgb(tBand.bg), [170, 162, 152], t);
} else if (daySnow >= 0.1) {
// Snow: pale → icy blue with depth.
rgb = mix([226, 234, 244], [176, 202, 232], Math.max(0, Math.min(1, daySnow / 1.5)));
} else if (showPrecip) {
// Rain: light → deep blue with amount (pure blue, no temperature hue).
rgb = mix([150, 176, 212], [44, 84, 150], Math.max(0, Math.min(1, dayPrecip / 8)));
} else if (avgCloud >= 30) {
// Cloud: light → dark grey with cover (pure grey, no temperature hue).
rgb = mix([205, 206, 208], [110, 115, 122], Math.max(0, Math.min(1, (avgCloud - 30) / 70)));
} else if (dayHi !== null && dayHi < 10) {
// Clear & cold: keep the cold temperature blue.
rgb = hex2rgb(tBand.bg);
} else {
// Clear & mild/warm: a plain sunny yellow.
rgb = hex2rgb('#f8e554');
}
const [wR, wG, wB] = rgb;
const wBgNeutral = isDanger const wBgNeutral = isDanger
? weatherGradientNeutral(wR, wG, wB, 0.15, 0.72) ? weatherGradientNeutral(wR, wG, wB, 0.15, 0.72)
: weatherGradientNeutral(wR, wG, wB); : weatherGradientNeutral(wR, wG, wB);
+101 -2
View File
@@ -16,7 +16,7 @@ import {
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp,
} from './physics.js'; } from './physics.js';
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel } from './utils.js'; import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel } from './utils.js';
import { UTCI_ENVIRONMENTS } from './config.js'; import { UTCI_ENVIRONMENTS, CROP_CALENDAR } from './config.js';
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, utciEnv }) { export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, utciEnv }) {
const env = UTCI_ENVIRONMENTS[utciEnv] ?? UTCI_ENVIRONMENTS.open; const env = UTCI_ENVIRONMENTS[utciEnv] ?? UTCI_ENVIRONMENTS.open;
@@ -380,7 +380,7 @@ export function computeWhyFeelsLike(row, env) {
// variant - active sub-variant key e.g. "running", "beach" (or null) // variant - active sub-variant key e.g. "running", "beach" (or null)
// skinType - Fitzpatrick skin type key for UV burn time // skinType - Fitzpatrick skin type key for UV burn time
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
export function computeGlanceSummary(todayRows, profile, variant, skinType, cols = null, vehicleSpeed = 'static') { export function computeGlanceSummary(todayRows, profile, variant, skinType, cols = null, vehicleSpeed = 'static', weekDays = null, lat = null) {
if (!todayRows || todayRows.length === 0) return []; if (!todayRows || todayRows.length === 0) return [];
const show = (key) => !cols || !!cols[key]; const show = (key) => !cols || !!cols[key];
@@ -546,6 +546,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
value: moistureLabel(soilM) ?? '-', value: moistureLabel(soilM) ?? '-',
alert: soilM != null && (soilM < 0.10 || soilM > 0.50), alert: soilM != null && (soilM < 0.10 || soilM > 0.50),
}] : []), }] : []),
...(weekDays ? computeCropAdvice(weekDays, lat) : []),
...(show('pollen') && maxPollen >= 10 ? [{ ...(show('pollen') && maxPollen >= 10 ? [{
icon: '🌿', icon: '🌿',
label: 'Grass pollen', label: 'Grass pollen',
@@ -722,4 +723,102 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
alert: maxPollen >= 50, alert: maxPollen >= 50,
}] : []), }] : []),
]; ];
}
// ------------------------------------------------------------------------
// computeCropAdvice(weekDays, lat) - Seasonal "Good to sow / harvest"
// advice for the Farming "At a glance" panel.
//
// Blends the current calendar month (which crops are in their sow/harvest
// window) with the upcoming week's weather (is the seedbed warm and
// workable, is there a dry spell to bring grain in?). Returns 0-2
// glance-style { icon, label, value, alert } items, appended after the
// standard farming insights.
//
// Parameters:
// weekDays - the `days` array: [{ key: 'YYYY-MM-DD', rows: [...] }, ...]
// lat - forecast latitude; < 0 flips the UK calendar by +6 months
// ------------------------------------------------------------------------
export function computeCropAdvice(weekDays, lat) {
if (!weekDays || weekDays.length === 0) return [];
const days = weekDays.slice(0, 7).filter(d => d && d.rows && d.rows.length);
if (days.length === 0) return [];
// "Now" - the month of the first available day (1-12).
const month = parseInt((days[0].key || '').slice(5, 7), 10);
if (!month) return [];
// Southern hemisphere: shift the stored UK months by +6 before testing.
const south = lat != null && lat < 0;
const inSeason = (months) => {
const shifted = south ? months.map(m => ((m + 5) % 12) + 1) : months;
return shifted.includes(month);
};
// Warmth the seedbed actually reaches this week: the highest of each day's
// peak surface soil temperature.
let weekSoilT = null;
for (const d of days) {
const peak = Math.max(-Infinity, ...d.rows.map(r => r.soilT0 ?? -Infinity));
if (peak > -Infinity && (weekSoilT == null || peak > weekSoilT)) weekSoilT = peak;
}
// Latest known soil moisture - saturated ground (>=45%) is unworkable.
let soilM = null;
for (const d of days) {
for (const r of d.rows) if (r.soilM != null) soilM = r.soilM;
}
const groundWet = soilM != null && soilM >= 0.45;
// Dry days this week: under 2 mm total rain and rain chance staying < 40%.
const dryNames = [];
for (const d of days) {
const totalRain = d.rows.reduce((s, r) => s + (r.precip ?? 0), 0);
const maxProb = Math.max(0, ...d.rows.map(r => r.precipProb ?? 0));
if (totalRain < 2 && maxProb < 40) {
const dt = new Date((d.key || '') + 'T00:00Z');
dryNames.push(dt.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }));
}
}
const hasDrySpell = dryNames.length > 0;
// Full crop-name list for the glance row.
const listCrops = (crops) => crops.map(c => c.label).join(', ');
const dryRange = () => dryNames.length === 1
? `dry ${dryNames[0]}`
: `dry ${dryNames[0]}${dryNames[dryNames.length - 1]}`;
const out = [];
// ── Good to sow ──────────────────────────────────────────────────────────
const sowSeason = CROP_CALENDAR.filter(c => inSeason(c.sow.months));
if (sowSeason.length) {
const ready = sowSeason.filter(c =>
weekSoilT != null && weekSoilT >= c.sow.minSoilT && !groundWet);
out.push(ready.length ? {
icon: '🌱', label: 'Good to sow', value: listCrops(ready), alert: false,
} : {
icon: '🌱', label: 'Good to sow',
value: groundWet ? 'Hold off — ground too wet' : 'Hold off — soil still cold',
alert: true,
});
}
// ── Good to harvest ──────────────────────────────────────────────────────
const harvestSeason = CROP_CALENDAR.filter(c => inSeason(c.harvest.months));
if (harvestSeason.length) {
// Grain/rape/onions need a dry spell; everything else can be lifted in window.
const ready = harvestSeason.filter(c => !c.harvest.dry || hasDrySpell);
const dryDriven = hasDrySpell && ready.some(c => c.harvest.dry);
out.push(ready.length ? {
icon: '🌾', label: 'Good to harvest',
value: dryDriven ? `${listCrops(ready)} (${dryRange()})` : listCrops(ready),
alert: false,
} : {
icon: '🌾', label: 'Good to harvest',
value: 'Hold — too wet to harvest grain', alert: true,
});
}
return out;
} }
+21
View File
@@ -239,3 +239,24 @@ export const COL_DESCRIPTIONS = {
aqi: { title: 'Air Quality Index', desc: 'European Air Quality Index (0100+), combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide. 020 = Good; 2040 = Fair; 4060 = Moderate; 6080 = Poor; 80100 = Very Poor; 100+ = Extremely Poor. Sourced from Copernicus CAMS.' }, aqi: { title: 'Air Quality Index', desc: 'European Air Quality Index (0100+), combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide. 020 = Good; 2040 = Fair; 4060 = Moderate; 6080 = Poor; 80100 = Very Poor; 100+ = Extremely Poor. Sourced from Copernicus CAMS.' },
pollen: { title: 'Pollen', desc: 'Pollen concentration in grains per cubic metre for the selected pollen type, sourced from the Copernicus CAMS pollen forecast. Low = <10; Moderate = 1050; High = 50200; Very High = 200+. Values vary by species and season.' }, pollen: { title: 'Pollen', desc: 'Pollen concentration in grains per cubic metre for the selected pollen type, sourced from the Copernicus CAMS pollen forecast. Low = <10; Moderate = 1050; High = 50200; Very High = 200+. Values vary by species and season.' },
}; };
// Seasonal crop calendar for the Farming "At a glance" sow/harvest advice.
// Months are 1-12 on a UK / Northern-temperate basis (auto-flipped +6 for the
// southern hemisphere in computeCropAdvice). `minSoilT` is the surface-soil
// germination floor in °C. `dry: true` marks crops that should only be
// harvested in a dry spell (grains, rape and onions need to be brought in /
// cured dry); `dry: false` crops can be lifted whenever they are in window.
export const CROP_CALENDAR = [
{ key: 'wheat', label: 'Wheat', icon: '🌾', sow: { months: [9, 10], minSoilT: 8 }, harvest: { months: [8, 9], dry: true } },
{ key: 'barley', label: 'Barley', icon: '🌾', sow: { months: [2, 3, 9, 10], minSoilT: 6 }, harvest: { months: [7, 8], dry: true } },
{ key: 'oats', label: 'Oats', icon: '🌾', sow: { months: [2, 3, 4], minSoilT: 5 }, harvest: { months: [8, 9], dry: true } },
{ key: 'osr', label: 'Oilseed rape', icon: '🌻', sow: { months: [8, 9], minSoilT: 10 }, harvest: { months: [7, 8], dry: true } },
{ key: 'fieldbean', label: 'Field beans', icon: '🫘', sow: { months: [2, 3, 10, 11], minSoilT: 3 }, harvest: { months: [8, 9], dry: true } },
{ key: 'potato', label: 'Potatoes', icon: '🥔', sow: { months: [3, 4, 5], minSoilT: 7 }, harvest: { months: [6, 7, 8, 9], dry: false } },
{ key: 'carrot', label: 'Carrots', icon: '🥕', sow: { months: [3, 4, 5, 6, 7], minSoilT: 7 }, harvest: { months: [6, 7, 8, 9, 10], dry: false } },
{ key: 'tomato', label: 'Tomatoes', icon: '🍅', sow: { months: [3, 4], minSoilT: 14 }, harvest: { months: [7, 8, 9], dry: false } },
{ key: 'onion', label: 'Onions', icon: '🧅', sow: { months: [3, 4], minSoilT: 7 }, harvest: { months: [7, 8], dry: true } },
{ key: 'pea', label: 'Peas', icon: '🟢', sow: { months: [3, 4, 5, 6], minSoilT: 8 }, harvest: { months: [6, 7, 8], dry: false } },
{ key: 'lettuce', label: 'Lettuce', icon: '🥬', sow: { months: [3, 4, 5, 6, 7, 8], minSoilT: 5 }, harvest: { months: [5, 6, 7, 8, 9], dry: false } },
{ key: 'beetroot', label: 'Beetroot', icon: '🟣', sow: { months: [4, 5, 6, 7], minSoilT: 7 }, harvest: { months: [7, 8, 9, 10], dry: false } },
];