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.
This commit is contained in:
@@ -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".
|
||||
|
||||
@@ -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);
|
||||
|
||||
+101
-2
@@ -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;
|
||||
}
|
||||
@@ -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 } },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user