From bf64122bc253561e3833a0e635759347a07efc53 Mon Sep 17 00:00:00 2001 From: fraxle Date: Sat, 27 Jun 2026 18:41:56 +0100 Subject: [PATCH] 3.2.1.0 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. --- assets/js/app.js | 3 + assets/js/components/DayTabs.js | 71 +++++++++++++--------- assets/js/compute.js | 103 +++++++++++++++++++++++++++++++- assets/js/config.js | 21 +++++++ 4 files changed, 167 insertions(+), 31 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index c225a9e..c5cd41f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -466,6 +466,9 @@ export function UTCIForecast() { skinType, visibleCols, 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". diff --git a/assets/js/components/DayTabs.js b/assets/js/components/DayTabs.js index 5d41be3..ae00668 100644 --- a/assets/js/components/DayTabs.js +++ b/assets/js/components/DayTabs.js @@ -45,26 +45,13 @@ import { h, Fragment } from '../../vendor/preact.js'; import { useRef, useEffect, useState } from '../../vendor/preact-hooks.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 { CloudIcon, PrecipIcon, CustomSelect } from '../components.js'; import { SubscribeModal } from './SubscribeModal.js'; 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 // 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 @@ -333,21 +320,47 @@ export function DayTabs({ const repDt = noonRow ? noonRow.dt : dDate; const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1; - // Base RGB for each weather condition — pastelised by weatherGradient() - const [wR, wG, wB] = (daySnow >= 0.1) ? [100, 160, 230] - : (dayHi > 44) ? [136, 0, 0] - : (dayHi > 38) ? [210, 60, 30] - : showPrecip ? [ 50, 120, 200] - : (dayHi > 32) ? [220, 110, 30] - : (dayHi > 26) ? [220, 180, 30] - : modalCloudCat === 'overcast' ? [100, 110, 130] - : (dayHi !== null && dayHi < 9) ? [ 60, 110, 180] - : modalCloudCat === 'scattered' ? [160, 150, 130] - : modalCloudCat === 'wispy' ? [200, 180, 130] - : [220, 190, 50]; - // Danger days keep a dark, saturated edge with a lighter centre. - const isDanger = dayHi !== null && dayHi > 44; - const wBg = weatherGradient(wR, wG, wB); + // Day-tab colour — PRIORITY model, no hue blending. + // Mixing a warm yellow with grey/blue passes through green, so rather + // than blend temperature with sky we pick ONE dimension by priority and + // only vary its shade: heat → snow → rain → cloud → sun (first wins). + // • Hot/extreme days always keep their heat colour (a safety signal). + // • Otherwise rain wins, then cloud, then a clear "sunny"/cold colour. + const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * Math.max(0, Math.min(1, t)))); + const hex2rgb = (hx) => { const n = parseInt(hx.replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }; + const tBand = dayHi !== null ? utciCategory(dayHi) : { bg: '#f8e554' }; + + const ccVals = repRows.map(r => r.cc).filter(v => isFinite(v)); + const avgCloud = ccVals.length ? ccVals.reduce((s, v) => s + v, 0) / ccVals.length : 0; + + const isHot = dayHi !== null && dayHi >= 29; // Hot band and above + 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 ? weatherGradientNeutral(wR, wG, wB, 0.15, 0.72) : weatherGradientNeutral(wR, wG, wB); diff --git a/assets/js/compute.js b/assets/js/compute.js index 2bd59a9..29f51ec 100644 --- a/assets/js/compute.js +++ b/assets/js/compute.js @@ -16,7 +16,7 @@ import { calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, } from './physics.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 }) { 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) // 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 []; const show = (key) => !cols || !!cols[key]; @@ -546,6 +546,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols value: moistureLabel(soilM) ?? '-', alert: soilM != null && (soilM < 0.10 || soilM > 0.50), }] : []), + ...(weekDays ? computeCropAdvice(weekDays, lat) : []), ...(show('pollen') && maxPollen >= 10 ? [{ icon: '🌿', label: 'Grass pollen', @@ -722,4 +723,102 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols 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; } \ No newline at end of file diff --git a/assets/js/config.js b/assets/js/config.js index 2a06801..20ff333 100644 --- a/assets/js/config.js +++ b/assets/js/config.js @@ -239,3 +239,24 @@ export const COL_DESCRIPTIONS = { aqi: { title: 'Air Quality Index', desc: 'European Air Quality Index (0–100+), combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide. 0–20 = Good; 20–40 = Fair; 40–60 = Moderate; 60–80 = Poor; 80–100 = 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 = 10–50; High = 50–200; 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 } }, +];