3.8
Improved 'Sow' and 'Harvest' calcs Better background on the event banners
This commit is contained in:
+23
-1
@@ -229,12 +229,34 @@ body {
|
|||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Safety-relevant weather (heat, frost, storm, wind, wet) — brass emphasis */
|
/* Safety-relevant weather (heat, frost, storm, wind, wet) — brass emphasis.
|
||||||
|
This is the fallback: when the event carries a weather tint, app.js
|
||||||
|
overrides background/border inline with the matching day-tab colour and
|
||||||
|
adds .is-tinted (below). */
|
||||||
.event-note.is-priority {
|
.event-note.is-priority {
|
||||||
background: #f0e4c4;
|
background: #f0e4c4;
|
||||||
border-color: #c8922a;
|
border-color: #c8922a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tinted warning banner — the hue now carries the "this is a warning" signal,
|
||||||
|
so the copy drops the brass accents for neutral dark tones that stay
|
||||||
|
readable on any of the palette's hues (orange, blue, grey). */
|
||||||
|
.event-note.is-tinted .event-note-msg {
|
||||||
|
color: #3f3222;
|
||||||
|
}
|
||||||
|
.event-note.is-tinted.is-priority .event-note-dates {
|
||||||
|
color: #56452c;
|
||||||
|
}
|
||||||
|
.event-note.is-tinted .event-note-dot {
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
.event-note.is-tinted .event-note-dot:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
.event-note.is-tinted .event-note-dot.active {
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
.event-note-slide {
|
.event-note-slide {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
|||||||
+15
-1
@@ -685,6 +685,20 @@ export function UTCIForecast() {
|
|||||||
const startFmt = fmtDate(ev.start);
|
const startFmt = fmtDate(ev.start);
|
||||||
const peakFmt = fmtDate(ev.peak);
|
const peakFmt = fmtDate(ev.peak);
|
||||||
const endFmt = fmtDate(ev.end);
|
const endFmt = fmtDate(ev.end);
|
||||||
|
// Warning banners take their background from the day tabs' weather
|
||||||
|
// palette (ev.tint, set in events/weather-checks.js) so a heat alert
|
||||||
|
// reads the same orange as a hot day tab and a rain alert the same
|
||||||
|
// blue. Pastelised hard — the banner is a wide block of body text, so
|
||||||
|
// it needs far more headroom than a small tab — and drawn edge-in to
|
||||||
|
// echo the tabs' radial "colour radiating inward" look.
|
||||||
|
const noteTintStyle = (isPriority && ev.tint) ? (() => {
|
||||||
|
const pale = (amt) => `rgb(${ev.tint.map(c => Math.round(c + (255 - c) * amt)).join(',')})`;
|
||||||
|
const edge = pale(0.55), core = pale(0.82);
|
||||||
|
return {
|
||||||
|
background: `linear-gradient(90deg, ${edge} 0%, ${core} 35%, ${core} 65%, ${edge} 100%)`,
|
||||||
|
borderColor: `rgb(${ev.tint.map(c => Math.round(c * 0.72)).join(',')})`,
|
||||||
|
};
|
||||||
|
})() : undefined;
|
||||||
const dateLine = (startFmt && endFmt)
|
const dateLine = (startFmt && endFmt)
|
||||||
? (startFmt === endFmt
|
? (startFmt === endFmt
|
||||||
? (peakFmt ? `Peak ${peakFmt}` : startFmt)
|
? (peakFmt ? `Peak ${peakFmt}` : startFmt)
|
||||||
@@ -693,7 +707,7 @@ export function UTCIForecast() {
|
|||||||
: `${startFmt} – ${endFmt}`))
|
: `${startFmt} – ${endFmt}`))
|
||||||
: null;
|
: null;
|
||||||
return html`
|
return html`
|
||||||
<div class=${`event-note${isPriority ? ' is-priority' : ''}`}>
|
<div class=${`event-note${isPriority ? ' is-priority' : ''}${noteTintStyle ? ' is-tinted' : ''}`} style=${noteTintStyle}>
|
||||||
<span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
|
<span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
|
||||||
<div class=${`event-note-slide${noteFading ? ' is-fading' : ''}`}>
|
<div class=${`event-note-slide${noteFading ? ' is-fading' : ''}`}>
|
||||||
<span class="event-note-emoji">${ev.emoji}</span>
|
<span class="event-note-emoji">${ev.emoji}</span>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
import { h, Fragment } from '../../vendor/preact.js';
|
import { h, Fragment } from '../../vendor/preact.js';
|
||||||
import htm from '../../vendor/htm.js';
|
import htm from '../../vendor/htm.js';
|
||||||
import { confidenceBand, utciCategory, petCategory, VEHICLE_SPEEDS, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
|
import { confidenceBand, utciCategory, petCategory, hexToRgb, mixRgb, rainTint, cloudTint, snowTint, VEHICLE_SPEEDS, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
|
||||||
import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js';
|
import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js';
|
||||||
import { calcVehicleInteriorTempPass } from '../physics.js';
|
import { calcVehicleInteriorTempPass } from '../physics.js';
|
||||||
import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js';
|
import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js';
|
||||||
@@ -300,8 +300,8 @@ export function DayTabs({
|
|||||||
// only vary its shade: heat → snow → rain → cloud → sun (first wins).
|
// only vary its shade: heat → snow → rain → cloud → sun (first wins).
|
||||||
// • Hot/extreme days always keep their heat colour (a safety signal).
|
// • Hot/extreme days always keep their heat colour (a safety signal).
|
||||||
// • Otherwise rain wins, then cloud, then a clear "sunny"/cold colour.
|
// • 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))));
|
// Shade ramps live in utils.js (WEATHER TINT PALETTE) so the warning
|
||||||
const hex2rgb = (hx) => { const n = parseInt(hx.replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
|
// event banner can colour itself from exactly the same source.
|
||||||
const catFn = isPetsProfile ? petCategory : utciCategory;
|
const catFn = isPetsProfile ? petCategory : utciCategory;
|
||||||
const tBand = dayHi !== null ? catFn(dayHi) : { bg: '#f8e554' };
|
const tBand = dayHi !== null ? catFn(dayHi) : { bg: '#f8e554' };
|
||||||
|
|
||||||
@@ -323,7 +323,7 @@ export function DayTabs({
|
|||||||
|
|
||||||
let rgb;
|
let rgb;
|
||||||
if (tempOnly) {
|
if (tempOnly) {
|
||||||
rgb = hex2rgb(tBand.bg);
|
rgb = hexToRgb(tBand.bg);
|
||||||
} else if (isHot) {
|
} else if (isHot) {
|
||||||
// Keep the heat hue; cloud only mutes it a touch (orange/red never
|
// Keep the heat hue; cloud only mutes it a touch (orange/red never
|
||||||
// greens) and rain does not override heat.
|
// greens) and rain does not override heat.
|
||||||
@@ -331,22 +331,22 @@ export function DayTabs({
|
|||||||
? (dayHi >= 49 ? 0.15 : dayHi >= 43 ? 0.30 : 0.45)
|
? (dayHi >= 49 ? 0.15 : dayHi >= 43 ? 0.30 : 0.45)
|
||||||
: (dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45);
|
: (dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45);
|
||||||
const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor;
|
const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor;
|
||||||
rgb = mix(hex2rgb(tBand.bg), [170, 162, 152], t);
|
rgb = mixRgb(hexToRgb(tBand.bg), [170, 162, 152], t);
|
||||||
} else if (daySnow >= 0.1) {
|
} else if (daySnow >= 0.1) {
|
||||||
// Snow: pale → icy blue with depth.
|
// Snow: pale → icy blue with depth.
|
||||||
rgb = mix([226, 234, 244], [176, 202, 232], Math.max(0, Math.min(1, daySnow / 1.5)));
|
rgb = snowTint(daySnow);
|
||||||
} else if (showPrecip) {
|
} else if (showPrecip) {
|
||||||
// Rain: light → deep blue with amount (pure blue, no temperature hue).
|
// 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)));
|
rgb = rainTint(dayPrecip);
|
||||||
} else if (avgCloud >= 30) {
|
} else if (avgCloud >= 30) {
|
||||||
// Cloud: light → dark grey with cover (pure grey, no temperature hue).
|
// 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)));
|
rgb = cloudTint(avgCloud);
|
||||||
} else if (dayHi !== null && dayHi < (isPetsProfile ? 2 : 10)) {
|
} else if (dayHi !== null && dayHi < (isPetsProfile ? 2 : 10)) {
|
||||||
// Clear & cold: keep the cold temperature blue.
|
// Clear & cold: keep the cold temperature blue.
|
||||||
rgb = hex2rgb(tBand.bg);
|
rgb = hexToRgb(tBand.bg);
|
||||||
} else {
|
} else {
|
||||||
// Clear & mild/warm: a plain sunny yellow.
|
// Clear & mild/warm: a plain sunny yellow.
|
||||||
rgb = hex2rgb('#f8e554');
|
rgb = hexToRgb('#f8e554');
|
||||||
}
|
}
|
||||||
const [wR, wG, wB] = rgb;
|
const [wR, wG, wB] = rgb;
|
||||||
|
|
||||||
|
|||||||
+176
-48
@@ -306,12 +306,15 @@ const AGG_FELT = [
|
|||||||
'soilT0', 'soilT6', 'shadeT', 'Ta', // Soil surface + root, Shade, Air
|
'soilT0', 'soilT6', 'shadeT', 'Ta', // Soil surface + root, Shade, Air
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Arithmetic mean of an array, or null when empty. Shared by aggregateRows
|
||||||
|
// and computeCropAdvice.
|
||||||
|
const mean = (vals) => vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
|
||||||
|
|
||||||
export function aggregateRows(rows, interval) {
|
export function aggregateRows(rows, interval) {
|
||||||
if (!rows || rows.length === 0 || !interval || interval <= 1) return rows;
|
if (!rows || rows.length === 0 || !interval || interval <= 1) return rows;
|
||||||
|
|
||||||
// Numbers for a field across the group, skipping null / NaN.
|
// Numbers for a field across the group, skipping null / NaN.
|
||||||
const nums = (group, f) => group.map(r => r[f]).filter(v => v != null && !isNaN(v));
|
const nums = (group, f) => group.map(r => r[f]).filter(v => v != null && !isNaN(v));
|
||||||
const mean = (vals) => vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
|
|
||||||
const maxV = (vals) => vals.length ? Math.max(...vals) : null;
|
const maxV = (vals) => vals.length ? Math.max(...vals) : null;
|
||||||
const felt = (vals) => {
|
const felt = (vals) => {
|
||||||
if (!vals.length) return null;
|
if (!vals.length) return null;
|
||||||
@@ -883,10 +886,59 @@ alert: peakAqi >= 60,
|
|||||||
// glance-style { icon, label, value, alert } items, appended after the
|
// glance-style { icon, label, value, alert } items, appended after the
|
||||||
// standard farming insights.
|
// standard farming insights.
|
||||||
//
|
//
|
||||||
|
// Each verdict is one of three tiers - clearly good (crops listed plain),
|
||||||
|
// borderline (crops listed with a "(marginal)" suffix) or hold. The middle
|
||||||
|
// tier exists so a technically-passing but shaky call doesn't read with the
|
||||||
|
// same confidence as an ideal one.
|
||||||
|
//
|
||||||
|
// Sowing looks at the DAILY MEAN soil temperature at 6 cm - drilling depth -
|
||||||
|
// rather than a surface peak, because bare soil at 0 cm swings 15 °C+ over a
|
||||||
|
// day and a sunny afternoon says nothing about the seedbed. It wants that
|
||||||
|
// threshold held for a run of consecutive days AND the soil trending warmer
|
||||||
|
// ("at temperature and rising"), plus workable moisture, no air frost ahead
|
||||||
|
// for tender crops, and no downpour due straight after drilling.
|
||||||
|
//
|
||||||
|
// Harvest wants a genuinely CONSECUTIVE dry run for grain/rape/onions, and
|
||||||
|
// gates root crops on soil moisture - lifting spuds off saturated ground
|
||||||
|
// means ruts, compaction and damaged tubers.
|
||||||
|
//
|
||||||
|
// Accuracy caveats worth knowing before trusting a verdict:
|
||||||
|
// - Soil fields come from ICON Global at ~11 km (see buildSoilUrl in
|
||||||
|
// hooks/useForecast.js) - a regional average, not this field.
|
||||||
|
// - The 0.45 m³/m³ wetness threshold is texture-agnostic: near saturation
|
||||||
|
// on sand, around field capacity on clay. A proper fix needs a soil
|
||||||
|
// texture lookup.
|
||||||
|
// - soil_moisture_0_to_1cm is the skin layer and dries within hours of
|
||||||
|
// rain, so it overstates workability after a shower.
|
||||||
|
// soil_moisture_3_to_9cm would be the better input if this is revisited.
|
||||||
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// weekDays - the `days` array: [{ key: 'YYYY-MM-DD', rows: [...] }, ...]
|
// weekDays - the `days` array: [{ key: 'YYYY-MM-DD', rows: [...] }, ...]
|
||||||
// lat - forecast latitude; < 0 flips the UK calendar by +6 months
|
// lat - forecast latitude; < 0 flips the UK calendar by +6 months
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Soil moisture bands shared by the sow and harvest gates (m³/m³). WET matches
|
||||||
|
// the "Saturated" cut in moistureLabel() so the two glance rows agree.
|
||||||
|
const SOIL_WET = 0.45;
|
||||||
|
const SOIL_BORDERLINE = 0.40;
|
||||||
|
|
||||||
|
// LONGEST run of consecutive entries satisfying `pred`, as { start, len }.
|
||||||
|
// Deliberately the longest and not the first: one unsettled day early in the
|
||||||
|
// week must not hide a good four-day spell behind it.
|
||||||
|
function bestRun(arr, pred) {
|
||||||
|
let best = { start: -1, len: 0 }, start = -1, len = 0;
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
if (pred(arr[i])) {
|
||||||
|
if (start < 0) start = i;
|
||||||
|
len++;
|
||||||
|
if (len > best.len) best = { start, len };
|
||||||
|
} else {
|
||||||
|
start = -1; len = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
export function computeCropAdvice(weekDays, lat) {
|
export function computeCropAdvice(weekDays, lat) {
|
||||||
if (!weekDays || weekDays.length === 0) return [];
|
if (!weekDays || weekDays.length === 0) return [];
|
||||||
const days = weekDays.slice(0, 7).filter(d => d && d.rows && d.rows.length);
|
const days = weekDays.slice(0, 7).filter(d => d && d.rows && d.rows.length);
|
||||||
@@ -903,69 +955,145 @@ export function computeCropAdvice(weekDays, lat) {
|
|||||||
return shifted.includes(month);
|
return shifted.includes(month);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Warmth the seedbed actually reaches this week: the highest of each day's
|
// ── Per-day aggregates ───────────────────────────────────────────────────
|
||||||
// peak surface soil temperature.
|
// One pass; everything below reads from these rather than re-scanning rows.
|
||||||
let weekSoilT = null;
|
const daily = days.map(d => {
|
||||||
for (const d of days) {
|
const at6 = d.rows.map(r => r.soilT6).filter(v => v != null);
|
||||||
const peak = Math.max(-Infinity, ...d.rows.map(r => r.soilT0 ?? -Infinity));
|
const at0 = d.rows.map(r => r.soilT0).filter(v => v != null);
|
||||||
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');
|
const dt = new Date((d.key || '') + 'T00:00Z');
|
||||||
dryNames.push(dt.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }));
|
return {
|
||||||
}
|
// 6 cm is drilling depth; fall back to the 0 cm skin only if a model
|
||||||
}
|
// swap drops the 6 cm field, so the row still says something.
|
||||||
const hasDrySpell = dryNames.length > 0;
|
soilMeanT: at6.length ? mean(at6) : (at0.length ? mean(at0) : null),
|
||||||
|
soilMMean: mean(d.rows.map(r => r.soilM).filter(v => v != null)),
|
||||||
|
rainTotal: d.rows.reduce((s, r) => s + (r.precip ?? 0), 0),
|
||||||
|
maxProb: Math.max(0, ...d.rows.map(r => r.precipProb ?? 0)),
|
||||||
|
minTa: Math.min(Infinity, ...d.rows.map(r => r.Ta ?? Infinity)),
|
||||||
|
name: dt.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Full crop-name list for the glance row.
|
// ── Seedbed signals ──────────────────────────────────────────────────────
|
||||||
|
// Longest consecutive run of days whose mean seedbed temp clears `minT`.
|
||||||
|
const runAt = (minT) => bestRun(daily, d => d.soilMeanT != null && d.soilMeanT >= minT).len;
|
||||||
|
|
||||||
|
// "At temperature AND rising": end of the week warmer than the start.
|
||||||
|
const early = mean(daily.slice(0, 3).map(d => d.soilMeanT).filter(v => v != null));
|
||||||
|
const late = mean(daily.slice(4, 7).map(d => d.soilMeanT).filter(v => v != null));
|
||||||
|
const rising = early != null && late != null && (late - early) >= 0.3;
|
||||||
|
|
||||||
|
// Best margin over a threshold across the week - a soil sitting well above
|
||||||
|
// the floor is a safer call than one scraping it.
|
||||||
|
const bestMean = Math.max(-Infinity, ...daily.map(d => d.soilMeanT ?? -Infinity));
|
||||||
|
const soilKnown = bestMean > -Infinity;
|
||||||
|
const marginOver = (minT) => (soilKnown ? bestMean - minT : null);
|
||||||
|
|
||||||
|
// Workability now, not seven days out: mean over the next 48 h only.
|
||||||
|
const nearMoist = mean(daily.slice(0, 2).map(d => d.soilMMean).filter(v => v != null));
|
||||||
|
const groundWet = nearMoist != null && nearMoist >= SOIL_WET;
|
||||||
|
const groundDamp = nearMoist != null && nearMoist >= SOIL_BORDERLINE && nearMoist < SOIL_WET;
|
||||||
|
|
||||||
|
// Air frost anywhere in the window rules out tender crops.
|
||||||
|
const frostAhead = daily.some(d => d.minTa < 0);
|
||||||
|
|
||||||
|
// A downpour onto a fresh seedbed caps it; downgrade, never block.
|
||||||
|
const postSowSoak = daily.slice(0, 3).reduce((s, d) => s + d.rainTotal, 0) > 25;
|
||||||
|
|
||||||
|
// ── Shared row plumbing ──────────────────────────────────────────────────
|
||||||
const listCrops = (crops) => crops.map(c => c.label).join(', ');
|
const listCrops = (crops) => crops.map(c => c.label).join(', ');
|
||||||
const dryRange = () => dryNames.length === 1
|
|
||||||
? `dry ${dryNames[0]}`
|
// Split a season list into ready / marginal by a per-crop classifier.
|
||||||
: `dry ${dryNames[0]}–${dryNames[dryNames.length - 1]}`;
|
const classify = (crops, fn) => {
|
||||||
|
const ready = [], marginal = [];
|
||||||
|
for (const c of crops) {
|
||||||
|
const v = fn(c);
|
||||||
|
if (v === 'ready') ready.push(c);
|
||||||
|
else if (v === 'marginal') marginal.push(c);
|
||||||
|
}
|
||||||
|
return { ready, marginal };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ready crops list plain; otherwise marginal crops carry the suffix. Keeping
|
||||||
|
// them separate means a plain list always means "genuinely good to go".
|
||||||
|
const buildRow = (icon, label, { ready, marginal }, readyValue, holdValue) => (
|
||||||
|
ready.length ? {
|
||||||
|
icon, label, value: readyValue(ready), alert: false, grp: 'surface',
|
||||||
|
} : marginal.length ? {
|
||||||
|
icon, label, value: `${listCrops(marginal)} (marginal)`, alert: false, grp: 'surface',
|
||||||
|
} : {
|
||||||
|
icon, label, value: holdValue, alert: true, grp: 'surface',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const out = [];
|
const out = [];
|
||||||
|
|
||||||
// ── Good to sow ──────────────────────────────────────────────────────────
|
// ── Good to sow ──────────────────────────────────────────────────────────
|
||||||
const sowSeason = CROP_CALENDAR.filter(c => inSeason(c.sow.months));
|
const sowSeason = CROP_CALENDAR.filter(c => inSeason(c.sow.months));
|
||||||
if (sowSeason.length) {
|
if (sowSeason.length) {
|
||||||
const ready = sowSeason.filter(c =>
|
const sowState = classify(sowSeason, (c) => {
|
||||||
weekSoilT != null && weekSoilT >= c.sow.minSoilT && !groundWet);
|
// Under glass: the outdoor seedbed simply doesn't apply.
|
||||||
out.push(ready.length ? {
|
if (c.sow.underCover) return 'ready';
|
||||||
icon: '🌱', label: 'Good to sow', value: listCrops(ready), alert: false, grp: 'surface',
|
// No soil reading at all is "unknown", not "fine" - don't guess.
|
||||||
} : {
|
if (!soilKnown) return 'no';
|
||||||
icon: '🌱', label: 'Good to sow',
|
const run = runAt(c.sow.minSoilT);
|
||||||
value: groundWet ? 'Hold off — ground too wet' : 'Hold off — soil still cold',
|
if (groundWet || run === 0) return 'no';
|
||||||
alert: true, grp: 'surface',
|
if (c.sow.tender && frostAhead) return 'no';
|
||||||
|
const margin = marginOver(c.sow.minSoilT);
|
||||||
|
const solid = run >= 3 && (rising || (margin != null && margin >= 2));
|
||||||
|
return (solid && !groundDamp && !postSowSoak) ? 'ready' : 'marginal';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Under-cover crops pass on the calendar alone, so on their own they must
|
||||||
|
// NOT read as a green light for the field - qualify them instead.
|
||||||
|
const onlyUnderCover = sowState.ready.length > 0
|
||||||
|
&& sowState.ready.every(c => c.sow.underCover);
|
||||||
|
const sowReadyValue = (ready) => onlyUnderCover
|
||||||
|
? `${listCrops(ready)} (under cover)`
|
||||||
|
: listCrops(ready);
|
||||||
|
|
||||||
|
// Hold reason by precedence: unknown beats wet beats cold beats frost.
|
||||||
|
const holdValue = !soilKnown ? 'Soil data unavailable'
|
||||||
|
: groundWet ? 'Hold off — ground too wet'
|
||||||
|
: sowSeason.some(c => runAt(c.sow.minSoilT) === 0) ? 'Hold off — soil still cold'
|
||||||
|
: 'Hold off — frost forecast';
|
||||||
|
|
||||||
|
out.push(buildRow('🌱', 'Good to sow', sowState, sowReadyValue, holdValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Good to harvest ──────────────────────────────────────────────────────
|
// ── Good to harvest ──────────────────────────────────────────────────────
|
||||||
const harvestSeason = CROP_CALENDAR.filter(c => inSeason(c.harvest.months));
|
const harvestSeason = CROP_CALENDAR.filter(c => inSeason(c.harvest.months));
|
||||||
if (harvestSeason.length) {
|
if (harvestSeason.length) {
|
||||||
// Grain/rape/onions need a dry spell; everything else can be lifted in window.
|
// A dry SPELL, not scattered dry days: under 2 mm and rain chance < 40%.
|
||||||
const ready = harvestSeason.filter(c => !c.harvest.dry || hasDrySpell);
|
const isDry = (d) => d.rainTotal < 2 && d.maxProb < 40;
|
||||||
const dryDriven = hasDrySpell && ready.some(c => c.harvest.dry);
|
const spell = bestRun(daily, isDry);
|
||||||
out.push(ready.length ? {
|
const dryRange = () => {
|
||||||
icon: '🌾', label: 'Good to harvest',
|
const names = daily.slice(spell.start, spell.start + spell.len).map(d => d.name);
|
||||||
value: dryDriven ? `${listCrops(ready)} (${dryRange()})` : listCrops(ready),
|
return names.length === 1
|
||||||
alert: false, grp: 'surface',
|
? `dry ${names[0]}`
|
||||||
} : {
|
: `dry ${names[0]}–${names[names.length - 1]}`;
|
||||||
icon: '🌾', label: 'Good to harvest',
|
};
|
||||||
value: 'Hold — too wet to harvest grain', alert: true, grp: 'surface',
|
|
||||||
|
const harvestState = classify(harvestSeason, (c) => {
|
||||||
|
// Grain, rape and onions must come in / cure dry.
|
||||||
|
if (c.harvest.dry) return spell.len >= 3 ? 'ready' : spell.len === 2 ? 'marginal' : 'no';
|
||||||
|
// Root crops are lifted by machine - saturated ground means ruts.
|
||||||
|
if (c.harvest.lift) return groundWet ? 'no' : groundDamp ? 'marginal' : 'ready';
|
||||||
|
// Hand-cut (lettuce): month window is enough.
|
||||||
|
return 'ready';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dryDriven = spell.len > 0 && harvestState.ready.some(c => c.harvest.dry);
|
||||||
|
const readyValue = (ready) => dryDriven
|
||||||
|
? `${listCrops(ready)} (${dryRange()})`
|
||||||
|
: listCrops(ready);
|
||||||
|
|
||||||
|
// Hold reason: only blame the ground when every stuck crop is a root crop.
|
||||||
|
// (A hold means nothing was ready or marginal, so the whole season is stuck.)
|
||||||
|
const holdValue = harvestSeason.every(c => c.harvest.lift)
|
||||||
|
? 'Hold — ground too wet to lift'
|
||||||
|
: 'Hold — too wet to harvest grain';
|
||||||
|
|
||||||
|
out.push(buildRow('🌾', 'Good to harvest', harvestState, readyValue, holdValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
+18
-8
@@ -297,21 +297,31 @@ export const COL_DESCRIPTIONS = {
|
|||||||
|
|
||||||
// Seasonal crop calendar for the Farming "At a glance" sow/harvest advice.
|
// 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
|
// Months are 1-12 on a UK / Northern-temperate basis (auto-flipped +6 for the
|
||||||
// southern hemisphere in computeCropAdvice). `minSoilT` is the surface-soil
|
// southern hemisphere in computeCropAdvice). `minSoilT` is the seedbed
|
||||||
// germination floor in °C. `dry: true` marks crops that should only be
|
// germination floor in °C, tested against the DAILY MEAN soil temperature at
|
||||||
// harvested in a dry spell (grains, rape and onions need to be brought in /
|
// 6 cm (drilling depth) rather than a surface peak.
|
||||||
// cured dry); `dry: false` crops can be lifted whenever they are in window.
|
//
|
||||||
|
// Optional flags, all defaulting to falsy:
|
||||||
|
// sow.tender - seedlings are killed by air frost, so an air frost in the
|
||||||
|
// forecast week rules the crop out.
|
||||||
|
// sow.underCover - sown indoors / under glass in its window, so the outdoor
|
||||||
|
// seedbed temperature and wetness gates don't apply.
|
||||||
|
// harvest.dry - must be brought in / cured dry (grains, rape, onions), so
|
||||||
|
// needs a run of consecutive dry days.
|
||||||
|
// harvest.lift - lifted by machine off the ground (root crops), so
|
||||||
|
// waterlogged soil means ruts, compaction and damaged roots.
|
||||||
|
// A crop with neither harvest flag (lettuce, hand-cut) passes on month alone.
|
||||||
export const CROP_CALENDAR = [
|
export const CROP_CALENDAR = [
|
||||||
{ key: 'wheat', label: 'Wheat', icon: '🌾', sow: { months: [9, 10], minSoilT: 8 }, harvest: { months: [8, 9], dry: true } },
|
{ 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: '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: '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: '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: '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: 'potato', label: 'Potatoes', icon: '🥔', sow: { months: [3, 4, 5], minSoilT: 7, tender: true }, harvest: { months: [6, 7, 8, 9], dry: false, lift: true } },
|
||||||
{ key: 'carrot', label: 'Carrots', icon: '🥕', sow: { months: [3, 4, 5, 6, 7], minSoilT: 7 }, harvest: { months: [6, 7, 8, 9, 10], 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, lift: true } },
|
||||||
{ key: 'tomato', label: 'Tomatoes', icon: '🍅', sow: { months: [3, 4], minSoilT: 14 }, harvest: { months: [7, 8, 9], dry: false } },
|
{ key: 'tomato', label: 'Tomatoes', icon: '🍅', sow: { months: [3, 4], minSoilT: 14, tender: true, underCover: true }, 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: '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: '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: '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 } },
|
{ key: 'beetroot', label: 'Beetroot', icon: '🟣', sow: { months: [4, 5, 6, 7], minSoilT: 7 }, harvest: { months: [7, 8, 9, 10], dry: false, lift: true } },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,8 +9,16 @@
|
|||||||
// To add a new weather event: write a checkXxx(rows, location) function
|
// To add a new weather event: write a checkXxx(rows, location) function
|
||||||
// below, export it, and add it to the checks[] array inside
|
// below, export it, and add it to the checks[] array inside
|
||||||
// getActiveEvents() (in events.js).
|
// getActiveEvents() (in events.js).
|
||||||
|
//
|
||||||
|
// Priority (safety) events also carry a `tint` — an [r,g,b] triple taken
|
||||||
|
// from the same WEATHER TINT PALETTE the day tabs colour themselves from
|
||||||
|
// (utils.js), so a heat banner reads the same orange as a hot day tab, a
|
||||||
|
// rain banner the same blue, and so on. app.js turns it into the banner
|
||||||
|
// background; events without a tint keep the default parchment banner.
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { utciCategory, hexToRgb, rainTint, cloudTint } from '../utils.js';
|
||||||
|
|
||||||
export function checkStargazing(rows) {
|
export function checkStargazing(rows) {
|
||||||
// Great stargazing: mostly clear night hours with low cloud.
|
// Great stargazing: mostly clear night hours with low cloud.
|
||||||
// Split night into contiguous runs (pre-dawn vs post-dusk) and evaluate
|
// Split night into contiguous runs (pre-dawn vs post-dusk) and evaluate
|
||||||
@@ -138,6 +146,8 @@ export function checkHeatSpike(rows) {
|
|||||||
message: msgs[severity],
|
message: msgs[severity],
|
||||||
color: severity === 'danger' ? '#3a0000' : severity === 'extreme-caution' ? '#4a1000' : '#5a2000',
|
color: severity === 'danger' ? '#3a0000' : severity === 'extreme-caution' ? '#4a1000' : '#5a2000',
|
||||||
textColor: '#ffd0b0',
|
textColor: '#ffd0b0',
|
||||||
|
// Same heat hue a day tab would use for this temperature.
|
||||||
|
tint: hexToRgb(utciCategory(maxTemp).bg),
|
||||||
type: 'weather',
|
type: 'weather',
|
||||||
nightOnly: false,
|
nightOnly: false,
|
||||||
isoRange, // only show cell icon during the hot hours
|
isoRange, // only show cell icon during the hot hours
|
||||||
@@ -230,6 +240,8 @@ export function checkWind(rows) {
|
|||||||
message: `Gusts to ${mph} mph. ${desc}`,
|
message: `Gusts to ${mph} mph. ${desc}`,
|
||||||
color,
|
color,
|
||||||
textColor,
|
textColor,
|
||||||
|
// Dry but windy — the day tabs' cloud-grey ramp, darkened by wind tier.
|
||||||
|
tint: cloudTint(30 + tier * 12),
|
||||||
type: 'weather',
|
type: 'weather',
|
||||||
nightOnly: false,
|
nightOnly: false,
|
||||||
isoRange,
|
isoRange,
|
||||||
@@ -259,6 +271,8 @@ export function checkWet(rows) {
|
|||||||
: `Moderate rain expected — up to ${maxPrecip.toFixed(1)} mm/h at peak. Keep a brolly handy.`,
|
: `Moderate rain expected — up to ${maxPrecip.toFixed(1)} mm/h at peak. Keep a brolly handy.`,
|
||||||
color: '#15212e',
|
color: '#15212e',
|
||||||
textColor: '#bcd6ee',
|
textColor: '#bcd6ee',
|
||||||
|
// Rain wins over cloud in the day-tab priority order, so use the blue ramp.
|
||||||
|
tint: rainTint(maxPrecip),
|
||||||
type: 'weather',
|
type: 'weather',
|
||||||
nightOnly: false,
|
nightOnly: false,
|
||||||
isoRange,
|
isoRange,
|
||||||
@@ -289,6 +303,8 @@ export function checkStorm(rows) {
|
|||||||
message: `Gusts to ${mph} mph with ${rainLabel} (${maxPrecip.toFixed(1)} mm/h). ${windDesc}`,
|
message: `Gusts to ${mph} mph with ${rainLabel} (${maxPrecip.toFixed(1)} mm/h). ${windDesc}`,
|
||||||
color,
|
color,
|
||||||
textColor,
|
textColor,
|
||||||
|
// Wind + rain: rain outranks cloud on the day tabs, so the blue ramp again.
|
||||||
|
tint: rainTint(maxPrecip),
|
||||||
type: 'weather',
|
type: 'weather',
|
||||||
nightOnly: false,
|
nightOnly: false,
|
||||||
isoRange,
|
isoRange,
|
||||||
@@ -314,6 +330,8 @@ export function checkFrost(rows) {
|
|||||||
: `Temperatures near freezing tonight (${minTemp.toFixed(1)}°C) — frost possible on exposed surfaces and vehicles.`,
|
: `Temperatures near freezing tonight (${minTemp.toFixed(1)}°C) — frost possible on exposed surfaces and vehicles.`,
|
||||||
color: '#0a1a2a',
|
color: '#0a1a2a',
|
||||||
textColor: '#c8e8ff',
|
textColor: '#c8e8ff',
|
||||||
|
// Same cold blue a day tab would use for this temperature.
|
||||||
|
tint: hexToRgb(utciCategory(minTemp).bg),
|
||||||
type: 'weather',
|
type: 'weather',
|
||||||
nightOnly: false,
|
nightOnly: false,
|
||||||
isoRange, // only show cell icon during the frosty hours
|
isoRange, // only show cell icon during the frosty hours
|
||||||
|
|||||||
+26
-4
@@ -56,6 +56,31 @@ export function utciCategory(u) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// WEATHER TINT PALETTE - single source of truth for "what colour is
|
||||||
|
// this weather", shared by the day tabs (components/DayTabs.js) and the
|
||||||
|
// warning event banner (events/weather-checks.js + app.js).
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// The day tabs pick ONE dimension by priority (heat -> snow -> rain ->
|
||||||
|
// cloud -> sun) and only vary its shade; these helpers are the shade
|
||||||
|
// ramps for the non-temperature dimensions. Temperature-driven tints
|
||||||
|
// come from utciCategory().bg / petCategory().bg instead.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
export const hexToRgb = (hx) => {
|
||||||
|
const n = parseInt(hx.replace('#', ''), 16);
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mixRgb = (a, b, t) =>
|
||||||
|
a.map((v, i) => Math.round(v + (b[i] - v) * Math.max(0, Math.min(1, t))));
|
||||||
|
|
||||||
|
// Rain: light -> deep blue with amount (mm over the period).
|
||||||
|
export const rainTint = (mm) => mixRgb([150, 176, 212], [44, 84, 150], mm / 8);
|
||||||
|
// Cloud: light -> dark grey with cover (%).
|
||||||
|
export const cloudTint = (pct) => mixRgb([205, 206, 208], [110, 115, 122], (pct - 30) / 70);
|
||||||
|
// Snow: pale -> icy blue with depth (cm).
|
||||||
|
export const snowTint = (cm) => mixRgb([226, 234, 244], [176, 202, 232], cm / 1.5);
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// PET_BANDS - same shape as UTCI_BANDS, recalibrated for pet skin/fur
|
// PET_BANDS - same shape as UTCI_BANDS, recalibrated for pet skin/fur
|
||||||
// instead of bare human skin.
|
// instead of bare human skin.
|
||||||
@@ -437,10 +462,7 @@ const SKY_KEYS_SETTING = [
|
|||||||
[ 90, '#1a7abf', '#60b8e8'],
|
[ 90, '#1a7abf', '#60b8e8'],
|
||||||
];
|
];
|
||||||
|
|
||||||
function hexToRgb(hex) {
|
/* hexToRgb is the exported one from the weather tint palette above. */
|
||||||
const n = parseInt(hex.slice(1), 16);
|
|
||||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
||||||
}
|
|
||||||
function rgbToHex([r, g, b]) {
|
function rgbToHex([r, g, b]) {
|
||||||
return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
|
return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,5 +193,11 @@
|
|||||||
"vehicle": 4,
|
"vehicle": 4,
|
||||||
"pets": 1
|
"pets": 1
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"2026-08-02": {
|
||||||
|
"visits": 7,
|
||||||
|
"profiles": {
|
||||||
|
"farming": 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user