diff --git a/assets/css/base.css b/assets/css/base.css index 10bf27b..e084285 100644 --- a/assets/css/base.css +++ b/assets/css/base.css @@ -229,12 +229,34 @@ body { 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 { background: #f0e4c4; 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 { display: flex; align-items: baseline; diff --git a/assets/js/app.js b/assets/js/app.js index 766791b..72cfe63 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -685,6 +685,20 @@ export function UTCIForecast() { const startFmt = fmtDate(ev.start); const peakFmt = fmtDate(ev.peak); 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) ? (startFmt === endFmt ? (peakFmt ? `Peak ${peakFmt}` : startFmt) @@ -693,7 +707,7 @@ export function UTCIForecast() { : `${startFmt} – ${endFmt}`)) : null; return html` -
+
${ev.emoji} diff --git a/assets/js/components/DayTabs.js b/assets/js/components/DayTabs.js index 1505526..70da23e 100644 --- a/assets/js/components/DayTabs.js +++ b/assets/js/components/DayTabs.js @@ -33,7 +33,7 @@ import { h, Fragment } from '../../vendor/preact.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 { calcVehicleInteriorTempPass } from '../physics.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). // • 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]; }; + // Shade ramps live in utils.js (WEATHER TINT PALETTE) so the warning + // event banner can colour itself from exactly the same source. const catFn = isPetsProfile ? petCategory : utciCategory; const tBand = dayHi !== null ? catFn(dayHi) : { bg: '#f8e554' }; @@ -323,7 +323,7 @@ export function DayTabs({ let rgb; if (tempOnly) { - rgb = hex2rgb(tBand.bg); + rgb = hexToRgb(tBand.bg); } else if (isHot) { // Keep the heat hue; cloud only mutes it a touch (orange/red never // 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 >= 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); + rgb = mixRgb(hexToRgb(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))); + rgb = snowTint(daySnow); } 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))); + rgb = rainTint(dayPrecip); } 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))); + rgb = cloudTint(avgCloud); } else if (dayHi !== null && dayHi < (isPetsProfile ? 2 : 10)) { // Clear & cold: keep the cold temperature blue. - rgb = hex2rgb(tBand.bg); + rgb = hexToRgb(tBand.bg); } else { // Clear & mild/warm: a plain sunny yellow. - rgb = hex2rgb('#f8e554'); + rgb = hexToRgb('#f8e554'); } const [wR, wG, wB] = rgb; diff --git a/assets/js/compute.js b/assets/js/compute.js index 536458b..bc88d27 100644 --- a/assets/js/compute.js +++ b/assets/js/compute.js @@ -306,12 +306,15 @@ const AGG_FELT = [ '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) { if (!rows || rows.length === 0 || !interval || interval <= 1) return rows; // 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 mean = (vals) => vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null; const maxV = (vals) => vals.length ? Math.max(...vals) : null; const felt = (vals) => { if (!vals.length) return null; @@ -883,10 +886,59 @@ alert: peakAqi >= 60, // glance-style { icon, label, value, alert } items, appended after the // 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: // weekDays - the `days` array: [{ key: 'YYYY-MM-DD', rows: [...] }, ...] // 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) { if (!weekDays || weekDays.length === 0) return []; 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); }; - // 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; - } + // ── Per-day aggregates ─────────────────────────────────────────────────── + // One pass; everything below reads from these rather than re-scanning rows. + const daily = days.map(d => { + const at6 = d.rows.map(r => r.soilT6).filter(v => v != null); + const at0 = d.rows.map(r => r.soilT0).filter(v => v != null); + const dt = new Date((d.key || '') + 'T00:00Z'); + 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. + 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' }), + }; + }); - // 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; + // ── 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; - // 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; + // "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; - // Full crop-name list for the glance row. + // 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 dryRange = () => dryNames.length === 1 - ? `dry ${dryNames[0]}` - : `dry ${dryNames[0]}–${dryNames[dryNames.length - 1]}`; + + // Split a season list into ready / marginal by a per-crop classifier. + 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 = []; // ── 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, grp: 'surface', - } : { - icon: '🌱', label: 'Good to sow', - value: groundWet ? 'Hold off — ground too wet' : 'Hold off — soil still cold', - alert: true, grp: 'surface', + const sowState = classify(sowSeason, (c) => { + // Under glass: the outdoor seedbed simply doesn't apply. + if (c.sow.underCover) return 'ready'; + // No soil reading at all is "unknown", not "fine" - don't guess. + if (!soilKnown) return 'no'; + const run = runAt(c.sow.minSoilT); + if (groundWet || run === 0) return 'no'; + 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 ────────────────────────────────────────────────────── 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, grp: 'surface', - } : { - icon: '🌾', label: 'Good to harvest', - value: 'Hold — too wet to harvest grain', alert: true, grp: 'surface', + // A dry SPELL, not scattered dry days: under 2 mm and rain chance < 40%. + const isDry = (d) => d.rainTotal < 2 && d.maxProb < 40; + const spell = bestRun(daily, isDry); + const dryRange = () => { + const names = daily.slice(spell.start, spell.start + spell.len).map(d => d.name); + return names.length === 1 + ? `dry ${names[0]}` + : `dry ${names[0]}–${names[names.length - 1]}`; + }; + + 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; diff --git a/assets/js/config.js b/assets/js/config.js index 4e7bb2c..929b8c3 100644 --- a/assets/js/config.js +++ b/assets/js/config.js @@ -297,21 +297,31 @@ export const COL_DESCRIPTIONS = { // 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. +// southern hemisphere in computeCropAdvice). `minSoilT` is the seedbed +// germination floor in °C, tested against the DAILY MEAN soil temperature at +// 6 cm (drilling depth) rather than a surface peak. +// +// 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 = [ { 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: '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, lift: true } }, + { 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: '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 } }, + { key: 'beetroot', label: 'Beetroot', icon: '🟣', sow: { months: [4, 5, 6, 7], minSoilT: 7 }, harvest: { months: [7, 8, 9, 10], dry: false, lift: true } }, ]; diff --git a/assets/js/events/weather-checks.js b/assets/js/events/weather-checks.js index c2aa1e3..5b7b9a2 100644 --- a/assets/js/events/weather-checks.js +++ b/assets/js/events/weather-checks.js @@ -9,8 +9,16 @@ // To add a new weather event: write a checkXxx(rows, location) function // below, export it, and add it to the checks[] array inside // 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) { // Great stargazing: mostly clear night hours with low cloud. // Split night into contiguous runs (pre-dawn vs post-dusk) and evaluate @@ -138,6 +146,8 @@ export function checkHeatSpike(rows) { message: msgs[severity], color: severity === 'danger' ? '#3a0000' : severity === 'extreme-caution' ? '#4a1000' : '#5a2000', textColor: '#ffd0b0', + // Same heat hue a day tab would use for this temperature. + tint: hexToRgb(utciCategory(maxTemp).bg), type: 'weather', nightOnly: false, isoRange, // only show cell icon during the hot hours @@ -230,6 +240,8 @@ export function checkWind(rows) { message: `Gusts to ${mph} mph. ${desc}`, color, textColor, + // Dry but windy — the day tabs' cloud-grey ramp, darkened by wind tier. + tint: cloudTint(30 + tier * 12), type: 'weather', nightOnly: false, 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.`, color: '#15212e', textColor: '#bcd6ee', + // Rain wins over cloud in the day-tab priority order, so use the blue ramp. + tint: rainTint(maxPrecip), type: 'weather', nightOnly: false, isoRange, @@ -289,6 +303,8 @@ export function checkStorm(rows) { message: `Gusts to ${mph} mph with ${rainLabel} (${maxPrecip.toFixed(1)} mm/h). ${windDesc}`, color, textColor, + // Wind + rain: rain outranks cloud on the day tabs, so the blue ramp again. + tint: rainTint(maxPrecip), type: 'weather', nightOnly: false, isoRange, @@ -314,6 +330,8 @@ export function checkFrost(rows) { : `Temperatures near freezing tonight (${minTemp.toFixed(1)}°C) — frost possible on exposed surfaces and vehicles.`, color: '#0a1a2a', textColor: '#c8e8ff', + // Same cold blue a day tab would use for this temperature. + tint: hexToRgb(utciCategory(minTemp).bg), type: 'weather', nightOnly: false, isoRange, // only show cell icon during the frosty hours diff --git a/assets/js/utils.js b/assets/js/utils.js index aa825d2..7bec911 100644 --- a/assets/js/utils.js +++ b/assets/js/utils.js @@ -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 // instead of bare human skin. @@ -437,10 +462,7 @@ const SKY_KEYS_SETTING = [ [ 90, '#1a7abf', '#60b8e8'], ]; -function hexToRgb(hex) { - const n = parseInt(hex.slice(1), 16); - return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; -} +/* hexToRgb is the exported one from the weather tint palette above. */ function rgbToHex([r, g, b]) { return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join(''); } diff --git a/data/tracking.json b/data/tracking.json index 3b1027d..dd67127 100644 --- a/data/tracking.json +++ b/data/tracking.json @@ -193,5 +193,11 @@ "vehicle": 4, "pets": 1 } + }, + "2026-08-02": { + "visits": 7, + "profiles": { + "farming": 2 + } } } \ No newline at end of file