Cycling hazards (new feature) Added cyclingRisk() in compute.js, which scores each hour 0, 1 or 2 (fine, care, risky) from gusts, wind, heavy rain, snow, ice, fog and lightning. Shows a ⚠ tag beside the hour in the table and the forecast cards, amber for care and red for risky, and it opens the event-tag popup with advice. At a glance now has a “Ride with care / Risky to ride” row, and the best cycling window leaves out hazard hours. When hours are grouped into 2h/3h/4h rows, bikeRisk takes the worst hour, and the reasons come from that same hour. Felt wind Added feltWindMs() in utils.js, which compresses strong wind on a log curve so SunSoak and Shade (felt) no longer over-chill in gales. “Why it feels like” uses the same softened wind, so its parts still add up to the shown value. Week Ahead / best day scoring Every profile is now judged over waking hours (7am–10pm) instead of daylight only, and home, vehicle, pets, airport, camping, festival and driver run round the clock. Cycling, urban walking and airport each get their own scoring. Airport weights wind and fog most, and urban walking uses a cooler comfort range. Leisure variants such as picnic, beach and dog walk now get a sunshine weight from cloud cover, in daylight only. Gusts over 31 mph count three times as much in the score and in the choice of the day’s icon. Days with no good window are still listed, with a low score and the reason shown in place of a time range. Each day’s popup now shows its top three influences instead of one. Simple view (cards + curve) The temperature curve always plots hourly data now, so it keeps its shape at 2h/3h/4h. Cards that cover several hours are centred over their whole block, and the curve draws a bracket and dashed guides over the hours each card covers. Detailed (rotated) table styling Cells are tighter (smaller padding and minimum widths), so more hours fit across. The hour row has a white background with a lighter rule under the header, and the time label gets extra top padding. Metric names and their units sit on one line, and gusts stack under the wind speed. Toolbar / profile row The “Quick” view button is renamed “Simple”, and the view toggle is bigger, with Edit columns dropping onto a new line on mobile. The profile row now shows a scene thumbnail of the active profile (DayTabs.js). In that row, the “Profile” and “Viewing” labels stack above their values, and Change is a solid 40px button in the row’s own colour. Config / data Dog Walking now shows the pet columns: fur surface, paw temperature and pet shade (config.js). data/tracking.json has 14 new lines of tracking data.
1790 lines
85 KiB
JavaScript
1790 lines
85 KiB
JavaScript
// ------------------------------------------------------------------------
|
||
// compute.js - Build the per-hour display rows from the raw API data.
|
||
//
|
||
// Pure-ish function: feed in (forecast, airQuality, location, vehicleType,
|
||
// vehicleVent, vehicleSpeed, buildingType, furColor) and get back
|
||
// { hourlyRows, days, utcOffsetMs }.
|
||
//
|
||
// Open-Meteo with timezone=auto returns local wall-clock strings like
|
||
// "2026-05-13T14:00" - no Z suffix. Two forms are used in each row:
|
||
// - String slices (iso.slice(...)) for display & day grouping
|
||
// - A true UTC Date (dt) for solarElevationDeg (which uses .getUTC*).
|
||
// ------------------------------------------------------------------------
|
||
|
||
import {
|
||
vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox,
|
||
calcConcreteTempPass, calcVehicleInteriorTempPass,
|
||
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp,
|
||
calcFurSurfaceTempPass,
|
||
} from './physics.js';
|
||
import { windCompass8, uvSplit, cloudCategory, precipPenalty, feltWindMs, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS, PET_BANDS } from './utils.js';
|
||
import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js';
|
||
|
||
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) {
|
||
const env = UTCI_ENVIRONMENTS[utciEnv] ?? UTCI_ENVIRONMENTS.open;
|
||
const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000;
|
||
|
||
// Build a fast lookup map from the air quality hourly data: ISO string - index.
|
||
// Normalise to "YYYY-MM-DDTHH" (13 chars) so forecast timestamps like
|
||
// "2026-05-17T14:00" match AQ timestamps like "2026-05-17T14".
|
||
const aqTimeMap = {};
|
||
if (airQuality?.hourly?.time) {
|
||
airQuality.hourly.time.forEach((t, i) => { aqTimeMap[t.slice(0, 13)] = i; });
|
||
}
|
||
const getAq = (field, iso) => {
|
||
if (!airQuality?.hourly?.[field]) return null;
|
||
const i = aqTimeMap[iso.slice(0, 13)];
|
||
if (i === undefined) return null;
|
||
return airQuality.hourly[field][i] ?? null;
|
||
};
|
||
|
||
const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => {
|
||
const h = forecast.hourly;
|
||
const Ta = h.temperature_2m[i];
|
||
const RH = h.relative_humidity_2m[i];
|
||
const dew = h.dew_point_2m ? h.dew_point_2m[i] : null;
|
||
const va = h.wind_speed_10m[i];
|
||
const wd = h.wind_direction_10m ? h.wind_direction_10m[i] : null;
|
||
const gust = h.wind_gusts_10m ? h.wind_gusts_10m[i] : null;
|
||
const dir = h.direct_radiation[i] || 0;
|
||
const dif = h.diffuse_radiation[i] || 0;
|
||
const glob = h.shortwave_radiation[i] || 0;
|
||
const cc = h.cloud_cover[i];
|
||
const ccLow = h.cloud_cover_low ? h.cloud_cover_low[i] : null;
|
||
const ccMid = h.cloud_cover_mid ? h.cloud_cover_mid[i] : null;
|
||
const ccHigh = h.cloud_cover_high ? h.cloud_cover_high[i] : null;
|
||
const uv = h.uv_index ? (h.uv_index[i] || 0) : 0;
|
||
const precip = h.precipitation[i] || 0;
|
||
const precipProb = h.precipitation_probability ? (h.precipitation_probability[i] ?? 0) : 0;
|
||
const lightning = h.lightning_potential ? (h.lightning_potential[i] ?? 0) : 0;
|
||
const cape = h.cape ? (h.cape[i] ?? 0) : 0;
|
||
const snow = h.snowfall[i] || 0;
|
||
const soilT0 = h.soil_temperature_0cm ? h.soil_temperature_0cm[i] : null;
|
||
const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null;
|
||
const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null;
|
||
// iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z).
|
||
// For display we slice the string directly - no Date object needed.
|
||
// For solarElevationDeg (which uses .getUTC* internally) we need the
|
||
// true UTC instant: treat the local time as UTC then subtract the offset.
|
||
// e.g. Brisbane UTC+10: local 14:00 - parse as UTC 14:00 - subtract 10h - UTC 04:00 -
|
||
// iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z).
|
||
// For solarElevationDeg (which uses .getUTC* internally) we need the
|
||
// true UTC instant: treat the local time as UTC then subtract the offset.
|
||
const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs);
|
||
const elev = solarElevationDeg(location.lat, location.lon, dt);
|
||
// Use direct_radiation (beam sunlight) + a fraction of diffuse for concrete.
|
||
// direct_radiation is zero on fully overcast days - far more accurate than
|
||
// shortwave_radiation which can be unreliably high even at 100% cloud cover.
|
||
// Diffuse (scattered light through cloud) contributes ~20% as much heat to
|
||
// a surface as direct beam, so we weight it accordingly.
|
||
const effectiveRad = dir + dif * 0.2;
|
||
// Apply environment modifier - adjust solar inputs and air temp for shaded environments.
|
||
const TaEnv = Ta + env.taOffset;
|
||
const dirEnv = dir * env.dirFactor;
|
||
const difEnv = dif * env.difFactor;
|
||
const globEnv = glob * env.globFactor;
|
||
// concreteT is now stamped in the two-pass section below (thermal lag).
|
||
// vehicleT is stamped in the two-pass section below (thermal lag).
|
||
const eh = vaporPressureHpa(Ta, RH);
|
||
// UTCI: the standard, unmodified benchmark. Computed from the raw
|
||
// open-ground observation only - NO Solar Model anywhere in it. Its whole
|
||
// job is to be the fixed reference SunSoak is read against, so it must
|
||
// not move when the user switches environment; if both numbers shifted
|
||
// together the comparison would say nothing. Only the SunSoak family
|
||
// (utciAdj, Shade, Tmrt) responds to the Solar Model.
|
||
const TmrtStd = calcTmrt(Ta, dir, dif, glob, elev);
|
||
const utci = utciApprox(Ta, TmrtStd, va, eh);
|
||
// SunSoak: same peer-reviewed polynomial, every input shifted by the
|
||
// active Solar Model (air temp via taOffset, radiation via dir/dif/glob
|
||
// factors), then the rain/snow soak penalty on top. Wind is softened
|
||
// via feltWindMs() so strong mast winds don't over-chill (see utils.js).
|
||
const vaFelt = feltWindMs(va);
|
||
const Tmrt = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev);
|
||
const utciEnv = utciApprox(TaEnv, Tmrt, vaFelt, eh);
|
||
const utciAdj = utciEnv + precipPenalty(precip, snow, va);
|
||
// Shade air temp: what a thermometer in this Solar Model's typical shade
|
||
// (building shadow, beach umbrella, canopy...) would read. Shares
|
||
// SunSoak's TaEnv baseline and env-reduced radiation, so the two stay
|
||
// consistent. Pet Shade reads this directly; the human Shade column
|
||
// below turns it into a felt temperature first.
|
||
const shadeAirT = calcShadeAirTemp(TaEnv, dirEnv + difEnv * 0.2, va, elev, env.shade);
|
||
// Shade (felt): SunSoak with the direct beam taken away — still outdoors,
|
||
// so the same wind, humidity and rain penalty apply. See
|
||
// calcShadeFeltTemp() for why this is a felt number and not the air temp.
|
||
const shadeT = calcShadeFeltTemp(shadeAirT, difEnv, vaFelt, eh, elev, env.shade)
|
||
+ precipPenalty(precip, snow, va);
|
||
// Derived
|
||
const compass = windCompass8(wd);
|
||
const { uvA, uvB } = uvSplit(uv, elev);
|
||
const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh);
|
||
// Visibility from the main forecast API (metres - km).
|
||
const visKm = (() => { const v = h.visibility ? h.visibility[i] : null; return v != null ? v / 1000 : null; })();
|
||
const aqi = getAq('european_aqi', iso);
|
||
const grassPollen = getAq('grass_pollen', iso);
|
||
const birchPollen = getAq('birch_pollen', iso);
|
||
const alderPollen = getAq('alder_pollen', iso);
|
||
const mugwortPollen= getAq('mugwort_pollen', iso);
|
||
const olivePollen = getAq('olive_pollen', iso);
|
||
const ragweedPollen= getAq('ragweed_pollen', iso);
|
||
// -- FUTURE FEATURE: Activity "What If" Modifier ---------------------------
|
||
// Add two extra columns driven by a user-selected activity level. These are
|
||
// intentionally kept SEPARATE from the core columns above so that baseline
|
||
// profile data stays consistent and comparable across profiles.
|
||
//
|
||
// The user picks an activity from a simple UI picker (no live data needed -
|
||
// this is a forecast/planning tool, not a tracker):
|
||
// Resting - Walking - Cycling - Running - Sport/Intense
|
||
//
|
||
// Two output columns only (keep it clean):
|
||
//
|
||
// adjustedSafeTime - baseline UV safe exposure time - an activity multiplier.
|
||
// Higher activity = shorter safe time, because:
|
||
// - metabolic heat raises core body temp
|
||
// - sweating washes away sunscreen faster
|
||
// - more skin blood flow = higher UV sensitivity
|
||
// Suggested multipliers (tune with real data):
|
||
// Resting: 1.0 (no change)
|
||
// Walking: 0.85
|
||
// Cycling: 0.75 *This will need a airflow calc as going at speed is cooling
|
||
// Running: 0.60 *This will need a airflow calc as going at speed is cooling
|
||
// Sport: 0.50 *This will need a airflow calc as going at speed is cooling
|
||
//
|
||
// *Danny Notes: These faster speed activities might need intergrating into the future "Vehicle Speed" calcs somehow?
|
||
//
|
||
// heatStressLevel - a simple label: 'Low' | 'Moderate' | 'High' | 'Very High'
|
||
// Derived from UTCI + activity heat load. A runner at
|
||
// UTCI 28-C should read 'High' even if a resting person
|
||
// would read 'Moderate' at the same UTCI.
|
||
// Colour code in the UI: - - - -
|
||
//
|
||
// Implementation sketch:
|
||
// 1. Accept 'activityLevel' as a new param to buildHourlyRows() alongside
|
||
// vehicleType, buildingType etc.
|
||
// 2. Define ACTIVITY_PRESETS in utils.js (multiplier + utciOffset per level).
|
||
// 3. Compute adjustedSafeTime = baseSafeTime * preset.multiplier
|
||
// 4. Compute heatStressLevel from (utci + preset.utciOffset) banded into labels.
|
||
// 5. Add both fields to the returned row object below.
|
||
// 6. In components.js, render these as optional columns that only appear when
|
||
// an activity other than 'Resting' is selected - keeps the default table clean.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
return {
|
||
iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob,
|
||
cc, ccLow, ccMid, ccHigh, cloudCat,
|
||
uv, uvA, uvB,
|
||
precip, precipProb, lightning, cape, snow,
|
||
soilT0, soilT6, soilM, shadeT,
|
||
// Pet Shade stays on the shade AIR temperature: the human Shade column
|
||
// is a UTCI felt number on a human comfort scale, which says nothing
|
||
// useful about a cat. It also aggregates as a MEAN rather than a worst
|
||
// case, so it needs its own field either way (same for pawT/concreteT).
|
||
petShadeT: shadeAirT,
|
||
effectiveRad,
|
||
elev, Tmrt, utci, utciAdj, eh, compass,
|
||
visKm, aqi,
|
||
grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen,
|
||
};
|
||
}) : [];
|
||
|
||
// Two-pass calculations: concrete thermal lag + indoor + vehicle cabin.
|
||
// All need the full hourly arrays so they can look back at previous
|
||
// hours. Run after hourlyRows is built, then stamp each row.
|
||
if (hourlyRows.length > 0) {
|
||
const TaArr = hourlyRows.map(r => r.Ta);
|
||
const globArr = hourlyRows.map(r => r.glob);
|
||
const elevArr = hourlyRows.map(r => r.elev);
|
||
const radArr = hourlyRows.map(r => r.effectiveRad);
|
||
const vaArr = hourlyRows.map(r => r.va);
|
||
const uvArr = hourlyRows.map(r => r.uv);
|
||
const cloudCatArr = hourlyRows.map(r => r.cloudCat);
|
||
const soilMArr = hourlyRows.map(r => r.soilM);
|
||
const precipArr = hourlyRows.map(r => r.precip);
|
||
const snowArr = hourlyRows.map(r => r.snow);
|
||
|
||
// Concrete surface temperature with thermal lag (1.5 h time constant).
|
||
// A slab baking in the sun retains heat when cloud rolls in, and takes
|
||
// a couple of hours of sunshine to fully heat up from a cold start.
|
||
const concreteTemps = calcConcreteTempPass(
|
||
TaArr, radArr, vaArr, elevArr, uvArr, cloudCatArr, soilMArr, precipArr, snowArr
|
||
);
|
||
hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; r.pawT = concreteTemps[i]; });
|
||
|
||
// First hour's date sets the seasonal ground temperature under the floor.
|
||
const startIso = hourlyRows[0]?.iso;
|
||
const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType, startIso);
|
||
const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType, startIso);
|
||
hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; });
|
||
|
||
// Vehicle cabin temperature with thermal lag. A parked vehicle climbs
|
||
// toward the hour's equilibrium rather than jumping to it, so a van that
|
||
// has been in the sun since morning reads hotter than the same van an
|
||
// hour after parking. vaArr feeds ambient wind into shell convection.
|
||
const vehicleTemps = calcVehicleInteriorTempPass(
|
||
TaArr, globArr, elevArr, vaArr, vehicleType, vehicleVent, vehicleSpeed
|
||
);
|
||
hourlyRows.forEach((r, i) => { r.vehicleT = vehicleTemps[i]; });
|
||
|
||
// Fur surface temperature with thermal lag - Pets profile.
|
||
const furAlbedo = (FUR_COLORS[furColor] || FUR_COLORS.brown).albedo;
|
||
const furTemps = calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo, uvArr, cloudCatArr);
|
||
hourlyRows.forEach((r, i) => { r.furSurfaceT = furTemps[i]; });
|
||
|
||
// Cycling hazard per hour - needs the previous hours' precip for ice.
|
||
hourlyRows.forEach((r, i) => {
|
||
const { level, why } = cyclingRisk(r, hourlyRows.slice(Math.max(0, i - 3), i));
|
||
r.bikeRisk = level;
|
||
r.bikeRiskWhy = why;
|
||
});
|
||
}
|
||
|
||
// Group those hourly rows into days for the day tabs.
|
||
const days = [];
|
||
hourlyRows.forEach(row => {
|
||
const key = row.iso.slice(0, 10);
|
||
let day = days.find(d => d.key === key);
|
||
if (!day) { day = { key, rows: [] }; days.push(day); }
|
||
day.rows.push(row);
|
||
});
|
||
|
||
// -- FUTURE FEATURE v2: Today Summary + Alert System --------------------------
|
||
// After grouping rows into days, generate a per-day summary object that powers
|
||
// a stylish "Today at a Glance" panel shown above or below the main dial.
|
||
//
|
||
// The summary is NOT a live alert/push system - it's a forecast digest that
|
||
// refreshes with the forecast data. Think of it as a smart briefing card.
|
||
//
|
||
// WHAT TO COMPUTE (per day, from that day's rows):
|
||
// - Peak UTCI+P and time it occurs - heat stress headline
|
||
// - Min UTCI+P and time - cold stress headline
|
||
// - Peak UV index and time - UV warning
|
||
// - Max precipitation rate and time - rain/ice warning
|
||
// - Max vehicle cabin temp - "dangerous to leave pets/children in car"
|
||
// - Max pollen level + type - pollen advisory
|
||
// - Road condition risk (low air temp + precip - ice risk)
|
||
//
|
||
// ALERT CATEGORIES (each generates a styled warning card if threshold exceeded):
|
||
// -- Heat warning UTCI+P > 32-C
|
||
// - Cold warning UTCI+P < 0-C
|
||
// -- UV warning UV index > 6
|
||
// -- Heavy rain precip > 4mm/h
|
||
// - Ice/road risk Ta < 3-C + any precip (or recent precip overnight)
|
||
// - Vehicle danger vehicleT > 35-C ("don't leave pets or children in car")
|
||
// - High pollen any pollen type > 50 grains/m-
|
||
//
|
||
// DESIGN NOTES:
|
||
// - Cards should be concise - one line of bold text + a short explanation
|
||
// - Colour-coded to match the existing UTCI stress band palette
|
||
// - Collapsible - show top 2-3 alerts by default, expand for full list
|
||
// - For today only (days[0]); optionally extend to day tabs in a later pass
|
||
// - The "X-C above seasonal norm" historical context line (see app.js comment)
|
||
// could live here too, as a subtle subheading under the dial temperature
|
||
//
|
||
// Suggested return shape - add to the return value below:
|
||
// daySummaries: days.map(day => buildDaySummary(day.rows))
|
||
//
|
||
// where buildDaySummary() is a new helper in this file (or a separate
|
||
// summary.js module if it grows large).
|
||
// -----------------------------------------------------------------------------
|
||
|
||
return { hourlyRows, days, utcOffsetMs };
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// cyclingRisk(r, prevRows) - How risky this hour is to take a bike out.
|
||
//
|
||
// Returns { level, why }: level 0 = fine, 1 = caution, 2 = risky; why is
|
||
// the list of hazards that set it, worst first, e.g. ['Gusts 42 mph'].
|
||
//
|
||
// Thresholds (mph = m/s x 2.237):
|
||
// Gusts >= 30 caution (crosswind shove) >= 40 risky
|
||
// Wind >= 20 caution (steering effort) >= 28 risky
|
||
// Rain >= 2 mm/h caution (spray, braking) >= 6 mm/h risky
|
||
// Snow any caution >= 0.2 cm/h risky
|
||
// Ice surface <= 2.5 C and wet surface <= 0.5 C and wet
|
||
// Fog visibility < 1 km caution < 0.2 km risky
|
||
// Lightning LPI >= 5 caution >= 25 risky
|
||
//
|
||
// "Surface" is the colder of air and bare-soil skin temperature - on a
|
||
// clear night the road chills well below the air. "Wet" is any rain or snow
|
||
// this hour or the three before it, or near-saturated air (frost / rime).
|
||
// ------------------------------------------------------------------------
|
||
const BIKE_GUST_MPH = [30, 40];
|
||
const BIKE_WIND_MPH = [20, 28];
|
||
const BIKE_RAIN_MMH = [2, 6];
|
||
const BIKE_SNOW_CMH = 0.2;
|
||
const BIKE_ICE_C = [2.5, 0.5];
|
||
const BIKE_FOG_KM = [1, 0.2];
|
||
const BIKE_LPI = [5, 25];
|
||
|
||
function cyclingRisk(r, prevRows = []) {
|
||
const hits = []; // [level, text]
|
||
const gust = (r.gust ?? r.va ?? 0) * 2.237;
|
||
const wind = (r.va ?? 0) * 2.237;
|
||
if (gust >= BIKE_GUST_MPH[0]) hits.push([gust >= BIKE_GUST_MPH[1] ? 2 : 1, `Gusts ${Math.round(gust)} mph`]);
|
||
else if (wind >= BIKE_WIND_MPH[0]) hits.push([wind >= BIKE_WIND_MPH[1] ? 2 : 1, `Wind ${Math.round(wind)} mph`]);
|
||
|
||
const precip = r.precip ?? 0;
|
||
const snow = r.snow ?? 0;
|
||
if (snow > 0) hits.push([snow >= BIKE_SNOW_CMH ? 2 : 1, 'Snow']);
|
||
else if (precip >= BIKE_RAIN_MMH[0]) hits.push([precip >= BIKE_RAIN_MMH[1] ? 2 : 1, 'Heavy rain']);
|
||
|
||
const surf = Math.min(r.Ta ?? 99, r.soilT0 ?? 99);
|
||
const wet = precip > 0 || snow > 0 || (r.RH ?? 0) >= 95
|
||
|| prevRows.some(p => (p.precip ?? 0) > 0 || (p.snow ?? 0) > 0);
|
||
if (wet && surf <= BIKE_ICE_C[0]) hits.push([surf <= BIKE_ICE_C[1] ? 2 : 1, surf <= BIKE_ICE_C[1] ? 'Ice likely' : 'Possible ice']);
|
||
|
||
if (r.visKm != null && r.visKm < BIKE_FOG_KM[0]) hits.push([r.visKm < BIKE_FOG_KM[1] ? 2 : 1, 'Fog']);
|
||
if ((r.lightning ?? 0) >= BIKE_LPI[0]) hits.push([r.lightning >= BIKE_LPI[1] ? 2 : 1, 'Lightning']);
|
||
|
||
hits.sort((a, b) => b[0] - a[0]);
|
||
return { level: hits.length ? hits[0][0] : 0, why: hits.map(h => h[1]) };
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// aggregateRows(rows, interval) - Compress a day's hourly rows into
|
||
// multi-hour buckets for the table view (1h / 2h / 3h / 4h).
|
||
//
|
||
// Buckets are CLOCK-ALIGNED from midnight, so a 2h view groups 00-01,
|
||
// 02-03, ...; 3h groups 00-02, 03-05, ...; etc. Each output row keeps the
|
||
// LANDING (first) hour's positional fields - iso, dt, solar elevation,
|
||
// global radiation, wind direction, sky/cloud category - so the little
|
||
// scope, clock label and wind vane all show the time the column lands on.
|
||
//
|
||
// Every other column is aggregated with the rule that fits its meaning:
|
||
// - SUM : precipitation totals (1mm/h x 3h = 3mm)
|
||
// - MAX : risk/peak columns (rain %, UV, gusts, managed indoor)
|
||
// - MEAN : smooth continuous quantities (RH, wind, cloud, Tmrt, ...)
|
||
// - FELT : worst-case temperature - the warmest hour if the block reaches
|
||
// >=20C (heat is the concern), otherwise the coldest hour (cold
|
||
// is the concern). Applies to EVERY temperature column the user
|
||
// judges comfort or risk by - SunSoak, Cabin, Indoors, Concrete,
|
||
// Soil, Shade and Air - so they never disagree with each other
|
||
// at 2h/3h/4h. Note Air is therefore a block worst case, not a
|
||
// block average, and will read hotter than a mean on a warm day.
|
||
//
|
||
// Derived render values (Delta, Burn) are recomputed downstream from the
|
||
// aggregated Air / UTCI / UV, so they stay consistent automatically.
|
||
//
|
||
// interval <= 1 returns the input untouched (zero behaviour change).
|
||
// ------------------------------------------------------------------------
|
||
const AGG_MEAN = [
|
||
'RH', 'dew', 'va', 'dir', 'dif', 'cc', 'ccLow', 'ccMid', 'ccHigh',
|
||
'soilM', 'Tmrt', 'eh', 'visKm', 'aqi', 'effectiveRad',
|
||
// Thermal columns that read as a plain block average rather than a worst
|
||
// case: Tmrt and Dew above, plus the pet trio. pawT/petShadeT mirror
|
||
// concreteT/shadeT hourly but must NOT inherit their FELT aggregation.
|
||
'furSurfaceT', 'pawT', 'petShadeT',
|
||
'grassPollen', 'birchPollen', 'alderPollen', 'mugwortPollen', 'olivePollen', 'ragweedPollen',
|
||
];
|
||
const AGG_MAX = ['precipProb', 'uv', 'uvA', 'uvB', 'gust', 'bikeRisk'];
|
||
const AGG_SUM = ['precip', 'snow'];
|
||
// Every temperature the user reads as "how hot/cold does this actually get"
|
||
// shares one rule, so a 3h row never disagrees with the columns beside it.
|
||
const AGG_FELT = [
|
||
'utci', 'utciAdj', // SunSoak
|
||
'vehicleT', 'concreteT', // Cabin, Concrete
|
||
'indoorT', 'managedT', // Indoors + Managed Indoors (+ Pet Home)
|
||
'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 maxV = (vals) => vals.length ? Math.max(...vals) : null;
|
||
const felt = (vals) => {
|
||
if (!vals.length) return null;
|
||
const hi = Math.max(...vals);
|
||
return hi >= 20 ? hi : Math.min(...vals);
|
||
};
|
||
|
||
// Split rows into clock-aligned buckets by floor(localHour / interval).
|
||
const buckets = [];
|
||
let cur = null, curKey = null;
|
||
for (const r of rows) {
|
||
const hour = parseInt(r.iso.slice(11, 13), 10);
|
||
const key = Math.floor(hour / interval);
|
||
if (key !== curKey) { cur = []; buckets.push(cur); curKey = key; }
|
||
cur.push(r);
|
||
}
|
||
|
||
return buckets.map(group => {
|
||
// Start from the landing row so positional fields (iso, dt, elev, glob,
|
||
// wd, compass, cloudCat) carry through unchanged, then overwrite the
|
||
// aggregatable columns.
|
||
const out = { ...group[0] };
|
||
AGG_MEAN.forEach(f => { out[f] = mean(nums(group, f)); });
|
||
AGG_MAX.forEach(f => { out[f] = maxV(nums(group, f)); });
|
||
AGG_SUM.forEach(f => { out[f] = group.reduce((a, r) => a + (r[f] || 0), 0); });
|
||
AGG_FELT.forEach(f => { out[f] = felt(nums(group, f)); });
|
||
// Cycling hazard reasons follow the bucket's worst hour.
|
||
out.bikeRiskWhy = group.reduce((w, r) => ((r.bikeRisk ?? 0) > (w.bikeRisk ?? 0) ? r : w), group[0]).bikeRiskWhy;
|
||
// Bucket span markers used by the table for "now" highlighting and for
|
||
// matching event cell-tags that fall on any hour within the bucket.
|
||
out.isoHours = group.map(r => r.iso.slice(0, 13));
|
||
out.isoEnd = group[group.length - 1].iso;
|
||
return out;
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// computeWhyFeelsLike(row, env) - Break down the felt-temp delta into its
|
||
// contributing factors for the "Why it feels like this" panel.
|
||
//
|
||
// Returns contributions in degrees C relative to plain air temperature.
|
||
// Positive = warmer than air temp, negative = cooler.
|
||
//
|
||
// Attribution method - sequential isolation using utciApprox:
|
||
// sunAndSky - Tmrt vs Ta. Mean radiant temp captures net heat from
|
||
// direct sun, sky scatter, and ground reflection combined.
|
||
// wind - UTCI with wind vs without (Tmrt = Ta, neutral RH 50%).
|
||
// Almost always negative - wind cools.
|
||
// humidity - UTCI at actual vapour pressure vs neutral RH 50%.
|
||
// Positive when muggy, near-zero when dry.
|
||
// environment - taOffset from the active UTCI environment modifier.
|
||
// e.g. urban +2.5, forest -2.0, desert +3.5.
|
||
// precipitation - precipPenalty() - always 0 or negative.
|
||
// ------------------------------------------------------------------------
|
||
export function computeWhyFeelsLike(row, env) {
|
||
if (!row) return null;
|
||
const { Ta, Tmrt, va, eh, precip, snow } = row;
|
||
const r1 = (v) => Math.round(v * 10) / 10;
|
||
// Same softened wind SunSoak uses, so the deltas still sum to utciAdj.
|
||
const vaFelt = feltWindMs(va);
|
||
|
||
// Environment-adjusted air temp (same as used in buildHourlyRows).
|
||
const TaEnv = Ta + (env ? env.taOffset : 0);
|
||
|
||
// Sequential UTCI isolation — all deltas are in the same UTCI-polynomial
|
||
// space so they add up: Ta + environment + sunAndSky + wind + humidity
|
||
// + precipitation ≈ SunSoak (utciAdj), within rounding.
|
||
//
|
||
// Tmrt already has env radiation scaling (dirFactor/difFactor/globFactor)
|
||
// baked in from buildHourlyRows, so each delta automatically reflects
|
||
// the active Solar Model without any extra work here.
|
||
const ehNeutral = vaporPressureHpa(TaEnv, 50);
|
||
const base = utciApprox(TaEnv, TaEnv, 0, ehNeutral); // ≈ TaEnv
|
||
|
||
// Radiation: effect of actual Tmrt vs no-radiation baseline (wind=0, RH=50%).
|
||
const sunAndSky = r1(utciApprox(TaEnv, Tmrt, 0, ehNeutral) - base);
|
||
|
||
// Wind: cooling effect of actual wind with solar present (RH still neutral).
|
||
const wind = r1(utciApprox(TaEnv, Tmrt, vaFelt, ehNeutral) - utciApprox(TaEnv, Tmrt, 0, ehNeutral));
|
||
|
||
// Humidity: actual vapour pressure vs neutral RH 50%, with solar and wind.
|
||
const humidity = r1(utciApprox(TaEnv, Tmrt, vaFelt, eh) - utciApprox(TaEnv, Tmrt, vaFelt, ehNeutral));
|
||
|
||
// Environment modifier - explicit temperature offset from the env config.
|
||
const environment = env ? r1(env.taOffset) : 0;
|
||
|
||
// Precipitation soak penalty - negative or zero.
|
||
const precipitation = precipPenalty(precip, snow, va);
|
||
|
||
return { base: r1(base), sunAndSky, wind, humidity, environment, precipitation };
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// computeGlanceSummary(todayRows, profile, variant, skinType) - Build the
|
||
// environment-aware "Today at a Glance" items for the current day.
|
||
//
|
||
// Returns an array of { icon, label, value, alert } objects (4-5 items).
|
||
// Content varies by profile and sub-variant so it stays relevant to
|
||
// whatever the user is actually doing.
|
||
//
|
||
// Parameters:
|
||
// todayRows - hourlyRows for the selected day
|
||
// profile - active profile key e.g. "farming", "vehicle", "home"
|
||
// 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', weekDays = null, lat = null, normals = null, dayKey = null) {
|
||
if (!todayRows || todayRows.length === 0) return [];
|
||
const show = (key) => !cols || !!cols[key];
|
||
|
||
const hhmm = (iso) => {
|
||
if (!iso) return null;
|
||
const h = parseInt(iso.slice(11, 13), 10);
|
||
const m = iso.slice(14, 16);
|
||
const period = h < 12 ? 'am' : 'pm';
|
||
const h12 = h % 12 || 12;
|
||
return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`;
|
||
};
|
||
const hhmmEnd = (iso) => {
|
||
if (!iso) return null;
|
||
const h = parseInt(iso.slice(11, 13), 10) + 1;
|
||
const m = iso.slice(14, 16);
|
||
const period = (h % 24) < 12 ? 'am' : 'pm';
|
||
const h12 = h % 12 || 12;
|
||
return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`;
|
||
};
|
||
const dayRows = todayRows.filter(r => r.elev > 0);
|
||
|
||
const peakRow = (field) => todayRows.reduce((best, r) =>
|
||
(r[field] != null && (best == null || r[field] > best[field])) ? r : best, null);
|
||
|
||
const peakRainRow = todayRows.reduce((best, r) =>
|
||
(r.precipProb != null && (best == null || r.precipProb > best.precipProb)) ? r : best, null);
|
||
const maxRainProb = peakRainRow ? (peakRainRow.precipProb ?? 0) : 0;
|
||
|
||
// All contiguous runs of rows that satisfy a predicate. Each run is
|
||
// { start, end, len }. Used so an "at a glance" window can report more than
|
||
// one range - e.g. good driving in the morning AND again in the evening.
|
||
const allWindows = (rows, cond) => {
|
||
const out = [];
|
||
let cur = null;
|
||
for (const r of rows) {
|
||
if (cond(r)) {
|
||
cur = cur ? { start: cur.start, end: r, len: cur.len + 1 } : { start: r, end: r, len: 1 };
|
||
} else if (cur) {
|
||
out.push(cur); cur = null;
|
||
}
|
||
}
|
||
if (cur) out.push(cur);
|
||
return out;
|
||
};
|
||
|
||
// Format the most significant windows as "h – h", one range per line, in
|
||
// chronological order. Caps at `max` ranges (longest kept) so a split day
|
||
// reads cleanly. Returns null when there are no windows. The newline is
|
||
// preserved by `white-space: pre-line` on .insight-value.
|
||
const formatWindows = (wins, max = 2) => {
|
||
if (!wins || wins.length === 0) return null;
|
||
return [...wins]
|
||
.sort((a, b) => b.len - a.len)
|
||
.slice(0, max)
|
||
.sort((a, b) => (a.start.iso < b.start.iso ? -1 : 1))
|
||
.map(w => `${hhmm(w.start.iso)} – ${hhmmEnd(w.end.iso)}`)
|
||
.join('\n');
|
||
};
|
||
|
||
const pollenLabel = (v) => {
|
||
if (v == null || v < 0) return null;
|
||
if (v < 10) return 'Low';
|
||
if (v < 50) return 'Moderate';
|
||
if (v < 200) return 'High';
|
||
return 'Very High';
|
||
};
|
||
|
||
const aqiLabel = (v) => {
|
||
if (v == null) return null;
|
||
if (v < 20) return 'Good';
|
||
if (v < 40) return 'Fair';
|
||
if (v < 60) return 'Moderate';
|
||
return 'Poor';
|
||
};
|
||
|
||
const lightningLabel = (v) => {
|
||
if (v <= 0) return null;
|
||
if (v < 5) return 'Low';
|
||
if (v < 25) return 'Moderate';
|
||
return 'High';
|
||
};
|
||
const peakLightningRow = todayRows.reduce((best, r) =>
|
||
(r.lightning ?? 0) > (best?.lightning ?? 0) ? r : best, null);
|
||
const peakLightning = peakLightningRow?.lightning ?? 0;
|
||
const lightningRisk = lightningLabel(peakLightning);
|
||
const lightningItem = lightningRisk ? [{
|
||
icon: '⚡',
|
||
label: 'Lightning risk',
|
||
value: `${lightningRisk} at ${hhmm(peakLightningRow.iso)}`,
|
||
alert: peakLightning >= 5,
|
||
grp: 'precip',
|
||
}] : [];
|
||
|
||
const moistureLabel = (v) => {
|
||
if (v == null) return null;
|
||
if (v < 0.15) return 'Dry';
|
||
if (v < 0.30) return 'Slightly dry';
|
||
if (v < 0.45) return 'Moist';
|
||
return 'Saturated';
|
||
};
|
||
|
||
const rainItem = {
|
||
icon: '🌧',
|
||
label: 'Rain risk',
|
||
value: maxRainProb > 0 && peakRainRow?.iso
|
||
? `${Math.round(maxRainProb)}% at ${hhmm(peakRainRow.iso)}`
|
||
: `${Math.round(maxRainProb)}%`,
|
||
alert: maxRainProb >= 60,
|
||
};
|
||
|
||
// ── Vs seasonal norms (climate anomalies) ──────────────────────────────
|
||
// One row comparing this day against the 1991-2020 norm for the date. The
|
||
// headline is the standard climate anomaly — the day's 24h AVERAGE vs the
|
||
// normal mean — with the day's HIGH vs the normal high carried alongside as
|
||
// the peak people actually notice. Kept in a single row because two rows
|
||
// sharing the icon and "1991-2020 … warmer" phrasing read as duplicates
|
||
// giving contradictory answers. An honest climate cue.
|
||
const climateItem = (() => {
|
||
if (!normals) return [];
|
||
const key = dayKey || todayRows[0]?.iso?.slice(0, 10);
|
||
if (!key) return [];
|
||
const LEAP_CUM = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
|
||
const doy = LEAP_CUM[parseInt(key.slice(5, 7), 10) - 1] + (parseInt(key.slice(8, 10), 10) - 1);
|
||
const taVals = todayRows.map(r => r.Ta).filter(v => v != null);
|
||
if (taVals.length === 0) return [];
|
||
const dayHigh = Math.max(...taVals);
|
||
const dayMean = taVals.reduce((a, b) => a + b, 0) / taVals.length;
|
||
// Either day-of-year table can have null slots — reduceNormals keeps the
|
||
// { high, mean } shape as long as one series survived — so treat each as
|
||
// independently optional.
|
||
const nHigh = normals.high?.[doy];
|
||
const nMean = normals.mean?.[doy];
|
||
const aHigh = nHigh != null ? dayHigh - nHigh : null;
|
||
const aMean = nMean != null ? dayMean - nMean : null;
|
||
// Show the row when EITHER anomaly is notable. The mean anomaly is usually
|
||
// the smaller of the two (a clear night pulls it back towards normal), so
|
||
// gating on it alone would hide days with a dramatic peak.
|
||
const notable = v => v != null && Math.abs(v) >= 3;
|
||
if (!notable(aHigh) && !notable(aMean)) return [];
|
||
const fmt = a => `${a >= 0 ? '+' : '−'}${Math.abs(a).toFixed(1)}°`;
|
||
const word = a => (a >= 0 ? 'warmer' : 'cooler');
|
||
// Each half keeps its own sign and word, so a day whose peak and mean fall
|
||
// on opposite sides of the norm still reads correctly.
|
||
// `sub` is the secondary figure — rendered inline on mobile, on its own
|
||
// line in the narrow desktop rail (see .insight-sub in ui.css).
|
||
let value, sub = null;
|
||
if (aMean != null && aHigh != null) {
|
||
value = `${fmt(aMean)} ${word(aMean)}`;
|
||
sub = `(${fmt(aHigh)} peak)`;
|
||
} else if (aMean != null) {
|
||
value = `${fmt(aMean)} ${word(aMean)}`;
|
||
} else {
|
||
value = `${fmt(aHigh)} peak ${word(aHigh)}`;
|
||
}
|
||
// Flagged off the high, matching the "5 degrees against the normal high" rule.
|
||
const alert = (aHigh != null ? aHigh : aMean) >= 5;
|
||
return [{ icon: '🌡', label: 'Pre-2020 24h Average', value, sub, alert, grp: 'ambient' }];
|
||
})();
|
||
|
||
// ── Good Driving Time ──────────────────────────────────────────────────
|
||
// Shown whenever a road speed (not Static) is selected and the vehicle cabin
|
||
// column is active - so it appears in both the Vehicle and Driver profiles.
|
||
// Longest run of hours, across the full day since lorries run day and night,
|
||
// that are NOT poor driving conditions: a hot cabin (>29 °C), heavy rain,
|
||
// snow/ice, fog/thick mist, or gale-force wind.
|
||
const GALE_MS = 39 / 2.237; // 39 mph gust = Gale (Force 8)
|
||
const drivingShown = show('vehicleT') && vehicleSpeed !== 'static';
|
||
const drivingWins = drivingShown
|
||
? allWindows(todayRows, r => {
|
||
const gustMs = r.gust ?? r.va;
|
||
return (r.vehicleT == null || r.vehicleT <= 29) && // cabin not dangerously hot
|
||
(r.precip == null || r.precip < 4) && // not heavy rain (mm/h)
|
||
(r.snow == null || r.snow === 0) && // no snow
|
||
(r.Ta == null || r.Ta > 1) && // no ice risk
|
||
(r.visKm == null || r.visKm >= 4) && // not fog / thick mist
|
||
(gustMs == null || gustMs < GALE_MS); // not gale-force wind
|
||
})
|
||
: [];
|
||
const drivingItem = {
|
||
icon: '🚚',
|
||
label: 'Good driving time',
|
||
value: formatWindows(drivingWins) ?? 'Drive with care',
|
||
alert: drivingWins.length === 0,
|
||
grp: 'felt',
|
||
};
|
||
|
||
// ── Farming ────────────────────────────────────────────────────────────
|
||
if (profile === 'farming') {
|
||
const fieldWins = allWindows(dayRows, r =>
|
||
r.utciAdj >= 8 && r.utciAdj <= 32 && r.precipProb < 30 && r.va < 12
|
||
);
|
||
const soilWarmRow = todayRows.find(r => r.soilT0 != null && r.soilT0 >= 10);
|
||
const soilM = todayRows.find(r => r.soilM != null)?.soilM ?? null;
|
||
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
|
||
|
||
return [
|
||
...climateItem,
|
||
{
|
||
icon: '⏱',
|
||
label: 'Best field work',
|
||
value: formatWindows(fieldWins) ?? 'No suitable window',
|
||
alert: fieldWins.length === 0,
|
||
grp: 'surface',
|
||
},
|
||
...(show('soilT') ? [{
|
||
icon: '🌱',
|
||
label: 'Soil warms to 10°C by',
|
||
value: soilWarmRow ? (hhmm(soilWarmRow.iso) === '00:00' ? 'All Day' : hhmm(soilWarmRow.iso)) : 'Not today',
|
||
alert: !soilWarmRow,
|
||
grp: 'surface',
|
||
}] : []),
|
||
...(show('precipProb') ? [rainItem] : []),
|
||
...lightningItem,
|
||
...(show('soilM') ? [{
|
||
icon: '💧',
|
||
label: 'Soil moisture',
|
||
value: moistureLabel(soilM) ?? '-',
|
||
alert: soilM != null && (soilM < 0.10 || soilM > 0.50),
|
||
grp: 'surface',
|
||
}] : []),
|
||
...(weekDays ? computeCropAdvice(weekDays, lat) : []),
|
||
...(show('pollen') && maxPollen >= 10 ? [{
|
||
icon: '🌿',
|
||
label: 'Grass pollen',
|
||
value: pollenLabel(maxPollen),
|
||
alert: maxPollen >= 50,
|
||
grp: 'airqual',
|
||
}] : []),
|
||
];
|
||
}
|
||
|
||
// ── Vehicle ────────────────────────────────────────────────────────────
|
||
if (profile === 'vehicle') {
|
||
const peakCabin = peakRow('vehicleT');
|
||
const dangerWins = allWindows(todayRows, r => r.vehicleT != null && r.vehicleT >= 29);
|
||
|
||
return [
|
||
...climateItem,
|
||
...(show('vehicleT') ? [{
|
||
icon: '🌡',
|
||
label: 'Peak cabin temp',
|
||
value: peakCabin ? `${Math.round(peakCabin.vehicleT)}° at ${hhmm(peakCabin.iso)}` : '-',
|
||
alert: !!(peakCabin && peakCabin.vehicleT > 31.5),
|
||
grp: 'felt',
|
||
}, {
|
||
icon: '🧒',
|
||
label: 'Children/pets in car',
|
||
value: dangerWins.length ? `Unsafe ${formatWindows(dangerWins)}` : 'Safe all day',
|
||
alert: dangerWins.length > 0,
|
||
grp: 'felt',
|
||
}] : []),
|
||
...(show('precipProb') ? [rainItem] : []),
|
||
...lightningItem,
|
||
...(drivingShown ? [drivingItem] : []),
|
||
{
|
||
icon: '🌤',
|
||
label: 'Best travel comfort',
|
||
value: (() => {
|
||
const best = todayRows.reduce((b, r) =>
|
||
(b == null || r.utciAdj < b.utciAdj) ? r : b, null);
|
||
return best ? `${hhmm(best.iso)} (${Math.round(best.utciAdj)}° felt)` : '-';
|
||
})(),
|
||
alert: false,
|
||
grp: 'felt',
|
||
},
|
||
];
|
||
}
|
||
|
||
// ── Home ───────────────────────────────────────────────────────────────
|
||
if (profile === 'home') {
|
||
const peakIndoor = peakRow('indoorT');
|
||
const peakManaged = peakRow('managedT');
|
||
const ventWins = allWindows(todayRows, r =>
|
||
r.Ta != null && r.indoorT != null && r.Ta < r.indoorT && r.precipProb < 20
|
||
);
|
||
const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
|
||
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
|
||
|
||
return [
|
||
...climateItem,
|
||
...(show('indoorT') ? [{
|
||
icon: '🌡',
|
||
label: 'Peak indoor (unmanaged)',
|
||
value: peakIndoor ? `${Math.round(peakIndoor.indoorT)}° at ${hhmm(peakIndoor.iso)}` : '-',
|
||
alert: !!(peakIndoor && peakIndoor.indoorT >= 28),
|
||
grp: 'felt',
|
||
}] : []),
|
||
...(show('managedT') ? [{
|
||
icon: '🌡',
|
||
label: 'Peak indoor (managed)',
|
||
value: peakManaged ? `${Math.round(peakManaged.managedT)}°` : '-',
|
||
alert: !!(peakManaged && peakManaged.managedT >= 28),
|
||
grp: 'felt',
|
||
}] : []),
|
||
...(show('indoorT') ? [{
|
||
icon: '🪟',
|
||
label: 'Open windows',
|
||
value: formatWindows(ventWins) ?? 'Keep closed',
|
||
alert: false,
|
||
grp: 'felt',
|
||
}] : []),
|
||
...lightningItem,
|
||
...(show('aqi') && peakAqi > 0 ? [{
|
||
icon: '💨',
|
||
label: 'Air quality',
|
||
value: aqiLabel(peakAqi),
|
||
alert: peakAqi >= 60,
|
||
}] : []),
|
||
...(show('pollen') && maxPollen >= 10 ? [{
|
||
icon: '🌼',
|
||
label: 'Pollen',
|
||
value: pollenLabel(maxPollen),
|
||
alert: maxPollen >= 50,
|
||
grp: 'airqual',
|
||
}] : []),
|
||
];
|
||
}
|
||
|
||
// ── Pets (cats / small dogs) ────────────────────────────────────────────
|
||
if (profile === 'pets') {
|
||
const peakFur = peakRow('furSurfaceT');
|
||
const peakShade = peakRow('petShadeT');
|
||
const peakIndoor = peakRow('indoorT');
|
||
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
|
||
const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
|
||
const carDangerWins = allWindows(todayRows, r => r.vehicleT != null && r.vehicleT >= 29);
|
||
|
||
return [
|
||
...(show('furSurfaceT') ? [{
|
||
icon: '🐾',
|
||
label: 'Sunsoak for pet',
|
||
value: peakFur ? `${Math.round(peakFur.furSurfaceT)}° at ${hhmm(peakFur.iso)}` : '-',
|
||
alert: !!(peakFur && peakFur.furSurfaceT >= 45),
|
||
grp: 'surface',
|
||
}] : []),
|
||
...(show('petShadeT') ? [{
|
||
icon: '🌳',
|
||
label: 'Shade for pet',
|
||
value: peakShade ? `${Math.round(peakShade.petShadeT)}° at ${hhmm(peakShade.iso)}` : '-',
|
||
alert: !!(peakShade && peakShade.petShadeT >= 26),
|
||
grp: 'ambient',
|
||
}] : []),
|
||
...(show('petHomeT') ? [{
|
||
icon: '🏠',
|
||
label: 'Pet at home',
|
||
value: peakIndoor ? `${Math.round(peakIndoor.indoorT)}° at ${hhmm(peakIndoor.iso)}` : '-',
|
||
alert: !!(peakIndoor && peakIndoor.indoorT >= 26),
|
||
grp: 'ambient',
|
||
}] : []),
|
||
...(show('vehicleT') ? [{
|
||
icon: '🧒',
|
||
label: 'Kids/pets in car',
|
||
value: carDangerWins.length ? `Unsafe ${formatWindows(carDangerWins)}` : 'Safe all day',
|
||
alert: carDangerWins.length > 0,
|
||
grp: 'felt',
|
||
}] : []),
|
||
...lightningItem,
|
||
...(show('aqi') && peakAqi > 0 ? [{
|
||
icon: '💨',
|
||
label: 'Air quality',
|
||
value: aqiLabel(peakAqi),
|
||
alert: peakAqi >= 60,
|
||
grp: 'airqual',
|
||
}] : []),
|
||
...(show('pollen') && maxPollen >= 10 ? [{
|
||
icon: '🌼',
|
||
label: 'Pollen',
|
||
value: pollenLabel(maxPollen),
|
||
alert: maxPollen >= 50,
|
||
grp: 'airqual',
|
||
}] : []),
|
||
];
|
||
}
|
||
|
||
// ── Activities - running / cycling ─────────────────────────────────────
|
||
if (variant === 'running' || variant === 'cycling') {
|
||
const isBike = variant === 'cycling';
|
||
// Any hazard hour (care or risky) is excluded from the "best" cycling
|
||
// window, so it can never overlap the hazard row below.
|
||
const coolWins = allWindows(dayRows, r =>
|
||
r.utciAdj >= 5 && r.utciAdj <= 22 && r.precipProb < 30 && !(isBike && r.bikeRisk >= 1)
|
||
);
|
||
// Cycling hazards across the whole day - commutes run in the dark too.
|
||
const bikeWins = isBike ? allWindows(todayRows, r => r.bikeRisk >= 1) : [];
|
||
const worstBike = isBike ? peakRow('bikeRisk') : null;
|
||
const bikeItem = isBike ? [{
|
||
icon: '⚠️',
|
||
label: worstBike?.bikeRisk >= 2 ? 'Risky to ride' : worstBike?.bikeRisk >= 1 ? 'Ride with care' : 'Ride hazards',
|
||
value: formatWindows(bikeWins) ?? 'No hazards today',
|
||
sub: worstBike?.bikeRisk >= 1 ? worstBike.bikeRiskWhy.join(', ') : null,
|
||
alert: worstBike?.bikeRisk >= 2,
|
||
grp: 'felt',
|
||
}] : [];
|
||
const peakUvRow = peakRow('uv');
|
||
const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
|
||
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
|
||
const burnMins = peakUvRow && peakUvRow.uv > 0
|
||
? burnLabel(sunburnMinutes(peakUvRow.uv, skinType))
|
||
: null;
|
||
|
||
return [
|
||
...climateItem,
|
||
{
|
||
icon: '⏱',
|
||
label: `Best ${variant} window`,
|
||
value: formatWindows(coolWins) ?? (isBike && bikeWins.length ? 'No safe window today' : 'No cool window today'),
|
||
alert: coolWins.length === 0,
|
||
grp: 'felt',
|
||
},
|
||
...bikeItem,
|
||
...(show('precipProb') ? [rainItem] : []),
|
||
...lightningItem,
|
||
...(show('burn') && burnMins ? [{
|
||
icon: '☀',
|
||
label: 'UV burn time (peak)',
|
||
value: burnMins,
|
||
alert: !!(peakUvRow && peakUvRow.uv >= 6),
|
||
grp: 'solar',
|
||
}] : []),
|
||
...(show('aqi') && peakAqi > 0 ? [{
|
||
icon: '💨',
|
||
label: 'Air quality',
|
||
value: aqiLabel(peakAqi),
|
||
alert: peakAqi >= 60,
|
||
}] : []),
|
||
...(show('pollen') && maxPollen >= 10 ? [{
|
||
icon: '🌿',
|
||
label: 'Pollen',
|
||
value: pollenLabel(maxPollen),
|
||
alert: maxPollen >= 50,
|
||
}] : []),
|
||
];
|
||
}
|
||
|
||
// ── Outdoors - beach, park, events etc. (and fallback) ─────────────────
|
||
const comfortWins = allWindows(dayRows, r =>
|
||
r.utciAdj >= 9 && r.utciAdj <= 26 && r.precipProb < 30
|
||
);
|
||
const peakFelt = peakRow('utciAdj');
|
||
const peakUvRow = peakRow('uv');
|
||
const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0));
|
||
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
|
||
const burnMins = peakUvRow && peakUvRow.uv > 0
|
||
? burnLabel(sunburnMinutes(peakUvRow.uv, skinType))
|
||
: null;
|
||
|
||
return [
|
||
...climateItem,
|
||
...(drivingShown ? [drivingItem] : []),
|
||
{
|
||
icon: '🌤',
|
||
label: 'Comfortable window',
|
||
value: formatWindows(comfortWins) ?? 'No comfortable window',
|
||
alert: comfortWins.length === 0,
|
||
grp: 'felt',
|
||
},
|
||
{
|
||
icon: '🌡',
|
||
label: 'Peak felt temp',
|
||
value: peakFelt ? `${Math.round(peakFelt.utciAdj)}° at ${hhmm(peakFelt.iso)}` : '-',
|
||
alert: !!(peakFelt && (peakFelt.utciAdj >= 32 || peakFelt.utciAdj < 0)),
|
||
grp: 'felt',
|
||
},
|
||
...((show('burn') || show('uvA')) && burnMins ? [{
|
||
icon: '☀',
|
||
label: 'UV burn time (peak)',
|
||
value: burnMins,
|
||
alert: !!(peakUvRow && peakUvRow.uv >= 6),
|
||
grp: 'solar',
|
||
}] : []),
|
||
...(show('precipProb') ? [rainItem] : []),
|
||
...lightningItem,
|
||
...(show('aqi') && peakAqi > 0 ? [{
|
||
icon: '💨',
|
||
label: 'Air quality',
|
||
value: aqiLabel(peakAqi),
|
||
alert: peakAqi >= 60,
|
||
grp: 'airqual',
|
||
}] : []),
|
||
...(show('pollen') && maxPollen >= 10 ? [{
|
||
icon: '🌿',
|
||
label: 'Pollen',
|
||
value: pollenLabel(maxPollen),
|
||
alert: maxPollen >= 50,
|
||
grp: 'airqual',
|
||
}] : []),
|
||
];
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// 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.
|
||
//
|
||
// 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;
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// computeBestDay(weekDays, nowLocalISO) - every day in the
|
||
// next week with a decent unbroken run of pleasant outdoor hours, for
|
||
// "The Week Ahead".
|
||
//
|
||
// The app already works out comfort windows for the selected day, but
|
||
// comparing days meant tapping through all fourteen tabs. This answers the
|
||
// question the day tabs make you do by hand.
|
||
//
|
||
// Naming a single winner was too thin to plan around: knowing Saturday is
|
||
// the best day says nothing about whether Sunday is also fine, or whether
|
||
// the whole week is a washout. So it lists the days that qualify, in date
|
||
// order, each with a 0-100 score so they can be compared at a glance
|
||
// without reordering them out of the order you plan in.
|
||
//
|
||
// Deliberately NOT part of computeGlanceSummary: "At a glance" describes the
|
||
// one selected day, and a week-scoped line reads as a category error inside
|
||
// it. It renders in its own panel below the rail instead.
|
||
//
|
||
// Scored on utciAdj (SunSoak) rather than the profile's own main field.
|
||
// Every row has it whatever profile is active, and it is the number that
|
||
// actually answers "would I enjoy being outside" - unlike vehicleT or
|
||
// indoorT, where a "best day" framing would be meaningless.
|
||
//
|
||
// Waking hours only (WAKING_HOURS, 7am-10pm, dark or not - the winter
|
||
// commute and evening walk count; isAllDay profiles run 24 hours), and
|
||
// hours already past today are skipped, so a warm
|
||
// morning that has been and gone can't win.
|
||
// ------------------------------------------------------------------------
|
||
|
||
const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for
|
||
const PICK_MAX_DAYS = 7; // the whole week, when the whole week is good
|
||
|
||
// Said when no day qualifies, phrased for what was actually being judged -
|
||
// "no settled outdoor windows" is meaningless when the panel was reading a
|
||
// car cabin.
|
||
const EMPTY_VALUE = {
|
||
utciAdj: 'no settled outdoor windows',
|
||
vehicleT: 'no comfortable cabin windows',
|
||
indoorT: 'no comfortable indoor windows',
|
||
furSurfaceT: 'no safe windows for a pet outside',
|
||
};
|
||
|
||
// ── The four bands ───────────────────────────────────────────────────────
|
||
// Each is [ideal, limit]: at the ideal end an hour scores full marks on that
|
||
// measure, at the limit it scores zero, and past the limit the hour is not a
|
||
// day-out hour at all. That split is what lets the panel say "Thursday is
|
||
// fine, Saturday is better" instead of only ever answering yes or no.
|
||
//
|
||
// SunSoak is the exception: rather than one ideal point it has a flat top
|
||
// spanning the Comfortable and Warm thermal-stress bands - anywhere in there
|
||
// is a good day out and splitting hairs between 20 and 25 would be false
|
||
// precision - then falls 100% to 0% across the Cool band below and the
|
||
// Caution band above. Past those it isn't a day-out hour at all.
|
||
//
|
||
// Read off UTCI_BANDS rather than written as numbers, so the panel and the
|
||
// table's colour bands can never drift apart: today that is a 19-27 plateau
|
||
// ramping out to 15 and 32.
|
||
const bandMax = (bands, label) => bands.find(b => b.label === label).max;
|
||
const plateau = (bands) => ({
|
||
min: bandMax(bands, 'Chilly'), // bottom of Cool - score 0
|
||
flatLo: bandMax(bands, 'Cool'), // bottom of Comfortable - score 1
|
||
flatHi: bandMax(bands, 'Warm'), // top of Warm - score 1
|
||
max: bandMax(bands, 'Caution'), // top of Caution - score 0
|
||
});
|
||
const SUNSOAK = plateau(UTCI_BANDS);
|
||
// Pets are judged on fur-surface temperature, which is coloured off PET_BANDS
|
||
// in the table - reading the plateau off the same array keeps the panel and
|
||
// the cell colours in step there too.
|
||
const PET_PLATEAU = plateau(PET_BANDS);
|
||
// Walking in town (to work, to the shops) is not lounging outside: people are
|
||
// dressed for the weather and moving, so a cool morning is fine. Full marks
|
||
// from 10 °C (bottom of Chilly) to the top of Warm, ramping to zero at 0 °C
|
||
// and at the top of Caution. Heat keeps the SunSoak ceiling - hot pavements
|
||
// are as bad for a walk as for a day out.
|
||
const URBAN_WALK = {
|
||
min: 0,
|
||
flatLo: bandMax(UTCI_BANDS, 'Cold'), // 10 - score 1
|
||
flatHi: SUNSOAK.flatHi,
|
||
max: SUNSOAK.max,
|
||
};
|
||
// Hours every profile is judged over, as local clock hours [from, to):
|
||
// 7am-10pm covers the commute, the day and the evening, dark or not.
|
||
// Daylight alone dropped winter mornings and evenings people are out in;
|
||
// round the clock let 3am windows win.
|
||
const WAKING_HOURS = { from: 7, to: 22 };
|
||
// Profiles and outdoors variants judged round the clock instead: travel,
|
||
// driving, the house, pets and overnight stays all happen at any hour.
|
||
const ALL_DAY_PROFILES = ['home', 'vehicle', 'pets'];
|
||
const ALL_DAY_VARIANTS = ['airport', 'camping', 'festival', 'driver'];
|
||
const isAllDay = (profile, variant) =>
|
||
ALL_DAY_PROFILES.includes(profile) ||
|
||
(profile === 'outdoors' && ALL_DAY_VARIANTS.includes(variant));
|
||
const BAND_GUST = { ideal: 15, limit: 25 }; // mph
|
||
const BAND_RAIN = { ideal: 0, limit: 25 }; // % chance
|
||
const BAND_SOIL = { ideal: 0.10, limit: 0.25 }; // m3/m3 - dry ground to soggy
|
||
const BAND_BIKE = { ideal: 0, limit: 2 }; // cyclingRisk level - fine to risky
|
||
const BAND_FOG = { ideal: 5, limit: 1 }; // visibility km - clear to fog (<1 km)
|
||
const BAND_SUN = { ideal: 20, limit: 90 }; // cloud cover % - sunny to overcast
|
||
|
||
// Sunshine. Thermal already credits the sun's warmth, but inside the comfort
|
||
// plateau a grey 18 °C and a bright 18 °C scored the same - and for a picnic,
|
||
// a festival or a dog walk the bright one is the better day. Leisure variants
|
||
// listed here move SUN_WEIGHT from thermal onto cloud cover. Quality only:
|
||
// cloud never breaks a window, it just costs it marks. Running is left out
|
||
// (overcast is often better for it), as are the travel, vehicle, indoor and
|
||
// pet profiles, where sunshine is no part of the answer.
|
||
const SUNNY_VARIANTS = ['dogwalk', 'cycling', 'hiking', 'photography', 'naturist',
|
||
'park', 'beach', 'events', 'festival', 'camping'];
|
||
const SUN_WEIGHT = 0.15;
|
||
|
||
// Severe gusts. Past BAND_GUST.limit an hour already scores zero on gust, but
|
||
// at its normal weight a gale cost a day less than being one degree under the
|
||
// SunSoak floor. Above GUST_SEVERE (31 mph, near-gale) the wind is THE story,
|
||
// so its weight in the score and its vote for the day's icon are multiplied.
|
||
const GUST_SEVERE = 31; // mph
|
||
const GUST_SEVERE_MULT = 3; // weight / icon-vote multiplier above it
|
||
const gustMph = (r) => (r.gust ?? r.va ?? 0) * 2.237;
|
||
const isSevere = (r) => gustMph(r) > GUST_SEVERE;
|
||
|
||
// The profile's measure weights for one hour, with gust boosted when severe.
|
||
function partWeights(r, crit) {
|
||
return isSevere(r) ? { ...crit.w, gust: crit.w.gust * GUST_SEVERE_MULT } : crit.w;
|
||
}
|
||
|
||
// 1 at the ideal end, 0 at the limit, clamped. Direction is taken from the
|
||
// two numbers, so it reads the same whether lower or higher is better.
|
||
function bandScore(v, { ideal, limit }) {
|
||
if (v == null) return null; // unknown - caller decides
|
||
const f = (limit - v) / (limit - ideal);
|
||
return Math.max(0, Math.min(1, f));
|
||
}
|
||
|
||
function plateauScore(v, pl) {
|
||
if (v == null) return null;
|
||
if (v >= pl.flatLo && v <= pl.flatHi) return 1;
|
||
const f = v < pl.flatLo
|
||
? (v - pl.min) / (pl.flatLo - pl.min)
|
||
: (pl.max - v) / (pl.max - pl.flatHi);
|
||
return Math.max(0, Math.min(1, f));
|
||
}
|
||
|
||
// ── What "a good window" means for the active profile ────────────────────
|
||
// The panel used to answer one question - "would I enjoy standing outside"
|
||
// - whatever profile was selected, which made it useless to the very users
|
||
// who had told the app what they were doing. It now scores the SAME field the
|
||
// rest of the app is built around for that profile (the day tabs, the dial,
|
||
// the main table column), so "the week ahead" answers the question the
|
||
// profile implies: when to drive, when the house is bearable, when to walk
|
||
// the dog.
|
||
//
|
||
// thermal - weight on the profile's main temperature field
|
||
// rain / gust / soil - the outdoor nuisances, only where they can matter
|
||
//
|
||
// Driving keeps rain and gust (both are what makes a journey unpleasant or
|
||
// unsafe) and drops soil, which says nothing about a road. Indoors drops all
|
||
// three: rain on the roof does not change whether the bedroom is sleepable.
|
||
// Daylight-only is likewise a solar concern - a cool cabin at 6am is a real
|
||
// answer to "when should I drive", so vehicle and indoor windows run around
|
||
// the clock.
|
||
function bestDayCriteria(profile, variant) {
|
||
// Cycling adds the bike-hazard score (bikeRisk): any hazard hour breaks
|
||
// the window, matching "Best cycling window" in At a glance. Inside a
|
||
// window bike always scores full, so it only weighs on no-window days -
|
||
// kept small (soil's share) so it doesn't inflate window scores.
|
||
if (profile === 'outdoors' && variant === 'cycling') {
|
||
return { field: 'utciAdj', pl: SUNSOAK, daylightOnly: false, hours: WAKING_HOURS,
|
||
w: { thermal: 0.40, rain: 0.25, gust: 0.20, soil: 0, bike: 0.15 } };
|
||
}
|
||
// Town walking: paving drains and dries fast, so wet ground is dropped;
|
||
// rain and wind are what spoil a walk, so they carry more of the score.
|
||
if (profile === 'outdoors' && variant === 'urban') {
|
||
return { field: 'utciAdj', pl: URBAN_WALK, daylightOnly: false, hours: WAKING_HOURS,
|
||
w: { thermal: 0.35, rain: 0.40, gust: 0.25, soil: 0 } };
|
||
}
|
||
// Airport / travel: wind is what delays and diverts flights and makes
|
||
// ferries and trains unpleasant, so it carries the most weight, with fog
|
||
// (low-visibility delays) next. Ground state is irrelevant on runways and
|
||
// platforms, and travellers are dressed for the trip, so the walking
|
||
// comfort range is used rather than SunSoak.
|
||
if (profile === 'outdoors' && variant === 'airport') {
|
||
return { field: 'utciAdj', pl: URBAN_WALK, daylightOnly: false, hours: WAKING_HOURS,
|
||
w: { thermal: 0.15, rain: 0.20, gust: 0.40, soil: 0, fog: 0.25 } };
|
||
}
|
||
// Every profile is judged over WAKING_HOURS; daylightOnly stays available
|
||
// but is off everywhere.
|
||
const { mainField } = deriveProfileMain(profile, variant);
|
||
switch (mainField) {
|
||
case 'vehicleT':
|
||
return { field: 'vehicleT', pl: SUNSOAK, daylightOnly: false, hours: WAKING_HOURS,
|
||
w: { thermal: 0.55, rain: 0.25, gust: 0.20, soil: 0 } };
|
||
case 'indoorT':
|
||
return { field: 'indoorT', pl: SUNSOAK, daylightOnly: false, hours: WAKING_HOURS,
|
||
w: { thermal: 1, rain: 0, gust: 0, soil: 0 } };
|
||
case 'furSurfaceT':
|
||
return { field: 'furSurfaceT', pl: PET_PLATEAU, daylightOnly: false, hours: WAKING_HOURS,
|
||
w: { thermal: 0.55, rain: 0.30, gust: 0.15, soil: 0 } };
|
||
default:
|
||
return { field: 'utciAdj', pl: SUNSOAK, daylightOnly: false, hours: WAKING_HOURS,
|
||
w: { thermal: 0.40, rain: 0.25, gust: 0.20, soil: 0.15 } };
|
||
}
|
||
}
|
||
|
||
// True when the hour falls inside the profile's clock-hour range, if it has one.
|
||
function inHours(r, crit) {
|
||
if (!crit.hours) return true;
|
||
const h = parseInt(r.iso.slice(11, 13), 10);
|
||
return h >= crit.hours.from && h < crit.hours.to;
|
||
}
|
||
|
||
function comfortableHour(r, crit) {
|
||
if (crit.daylightOnly && !(r.elev > 0)) return false;
|
||
if (!inHours(r, crit)) return false;
|
||
const t = r[crit.field];
|
||
if (t == null) return false;
|
||
if (t < crit.pl.min || t > crit.pl.max) return false;
|
||
// A measure that carries no weight for this profile can't exclude an hour
|
||
// either, or an indoor window would still be cut short by a shower.
|
||
if (crit.w.rain && (r.precipProb ?? 0) > BAND_RAIN.limit) return false;
|
||
if (crit.w.gust && (r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) return false;
|
||
// Soil comes from a separate feed and is null wherever that fetch fails or
|
||
// the model has no coverage - treat missing as passing, or a soil outage
|
||
// would silently empty the panel everywhere. Only the wet end excludes: dust
|
||
// -dry ground is a poor day out for the garden, not for the person on it.
|
||
if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) return false;
|
||
if (crit.w.bike && r.bikeRisk >= 1) return false;
|
||
// Visibility can be missing too - treat that as passing, as with soil.
|
||
if (crit.w.fog && r.visKm != null && r.visKm < BAND_FOG.limit) return false;
|
||
return true;
|
||
}
|
||
|
||
// Each measure's own 0-1 score for one hour, keyed so the same numbers can be
|
||
// both averaged into a quality score and read back afterwards to say WHICH
|
||
// measure cost the day its marks.
|
||
function hourParts(r, crit) {
|
||
return {
|
||
thermal: plateauScore(r[crit.field], crit.pl),
|
||
rain: bandScore(r.precipProb ?? 0, BAND_RAIN),
|
||
gust: bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST),
|
||
soil: bandScore(r.soilM, BAND_SOIL),
|
||
bike: bandScore(r.bikeRisk ?? 0, BAND_BIKE),
|
||
fog: bandScore(r.visKm, BAND_FOG),
|
||
// Daylight only - null after dark drops it from the average, so evening
|
||
// hours aren't marked down for a sky nobody can see.
|
||
sun: r.elev > 0 ? bandScore(r.cc, BAND_SUN) : null,
|
||
};
|
||
}
|
||
|
||
// How good an hour is WITHIN the bands, 0-1. Weighted by how much each
|
||
// measure actually decides whether the day was worth going out for.
|
||
function hourQuality(r, crit) {
|
||
const p = hourParts(r, crit);
|
||
const w = partWeights(r, crit);
|
||
const parts = Object.keys(p)
|
||
.map(k => [p[k], w[k]])
|
||
.filter(([v, w]) => v != null && w > 0);
|
||
const wsum = parts.reduce((s, [, w]) => s + w, 0);
|
||
return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0;
|
||
}
|
||
|
||
// ── Why a day scored what it scored ──────────────────────────────────────
|
||
// A calendar icon on every row said nothing the day name had not already
|
||
// said. The icon now names the single biggest reason the day is not a
|
||
// hundred, so a column of scores can be read without opening each day: three
|
||
// windy days and one hot one is a different week from four wet ones.
|
||
//
|
||
// The icon is always a weather influence - heat, cold, rain, wind, wet ground
|
||
// - never "the window was short". Length is a symptom: the window is short
|
||
// BECAUSE the morning was cold or it rained after three, and the times beside
|
||
// the icon already say how long it is. So when marks are lost to length, the
|
||
// hours outside the window are asked what ruled them out and that is what the
|
||
// row shows.
|
||
// One vocabulary for both routes to a cause - hours inside the window scoring
|
||
// poorly, and hours outside it being ruled out - so the same influence always
|
||
// reads the same way whichever way it cost the day marks.
|
||
const SHORTFALL_ICON = {
|
||
rain: { icon: '🌧️', name: 'Rain' },
|
||
gust: { icon: '💨', name: 'Wind' },
|
||
soil: { icon: '💧', name: 'Wet ground' },
|
||
hot: { icon: '🔥', name: 'Heat' },
|
||
cold: { icon: '🥶', name: 'Cold' },
|
||
bike: { icon: '⚠️', name: 'Unsafe to ride' },
|
||
fog: { icon: '🌫️', name: 'Fog' },
|
||
sun: { icon: '☁️', name: 'Cloudy' },
|
||
none: { icon: '✅', name: 'Nothing much against it' },
|
||
};
|
||
|
||
// The tooltip is the influence and what it cost, in points of the day's own
|
||
// score: "Heat = 34%" says both what is wrong and how much it matters, so a
|
||
// 90% row and a 60% row with the same icon are not read as the same warning.
|
||
const causeTip = (cause) =>
|
||
cause.cost ? `${cause.name} = ${cause.cost}%` : cause.name;
|
||
|
||
// Why one hour failed to qualify. An hour can fail on more than one count -
|
||
// a cold wet morning is both - so every breach is returned and the day is
|
||
// decided on which comes up most, not on which happens to be tested first.
|
||
function failReasons(r, crit) {
|
||
const out = [];
|
||
const t = r[crit.field];
|
||
if (t == null) return out;
|
||
if (t < crit.pl.min) out.push('cold');
|
||
if (t > crit.pl.max) out.push('hot');
|
||
if (crit.w.rain && (r.precipProb ?? 0) > BAND_RAIN.limit) out.push('rain');
|
||
if (crit.w.gust && (r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) out.push('gust');
|
||
if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) out.push('soil');
|
||
if (crit.w.bike && r.bikeRisk >= 1) out.push('bike');
|
||
if (crit.w.fog && r.visKm != null && r.visKm < BAND_FOG.limit) out.push('fog');
|
||
return out;
|
||
}
|
||
|
||
// What kept the rest of the day out of the window. Returns null when those
|
||
// hours give no reason at all - dark hours for a daylight profile, or a gap
|
||
// in the data - so the caller can fall back to the in-window measures rather
|
||
// than print a non-answer.
|
||
function shortBecause(c, crit) {
|
||
const inRun = new Set(c.run.map(r => r.iso));
|
||
const tally = {};
|
||
for (const r of c.usableRows) {
|
||
if (inRun.has(r.iso)) continue;
|
||
// A severe-gust hour outvotes a marginal cold or hot one.
|
||
for (const k of failReasons(r, crit)) {
|
||
const vote = (k === 'gust' && isSevere(r)) ? GUST_SEVERE_MULT : 1;
|
||
tally[k] = (tally[k] ?? 0) + vote;
|
||
}
|
||
}
|
||
// Cost is attached by the caller: those hours were ruled out entirely, so
|
||
// what this influence cost is the whole of the length shortfall.
|
||
const worst = Object.keys(tally).sort((a, b) => tally[b] - tally[a])[0];
|
||
return worst ? SHORTFALL_ICON[worst] : null;
|
||
}
|
||
|
||
// Below this the day is as good as the bands allow and picking a "cause"
|
||
// would be inventing one: at five points the strongest complaint about the
|
||
// day is worth a twentieth of its score, which is noise, not a reason to
|
||
// stay in. Set higher and genuinely breezy days went out labelled faultless.
|
||
const SHORTFALL_FLOOR = 0.05;
|
||
|
||
// Marks lost, as whole points of the 0-100 score the row already shows, so
|
||
// the tooltip's number and the bar's number are in the same units.
|
||
const pts = (frac) => Math.round(frac * 100);
|
||
|
||
// Returns up to three influences ({ icon, name, cost }), worst first.
|
||
function limitingFactors(c, crit, span) {
|
||
// A day with no window has no run to read the measures from, so its whole
|
||
// usable day stands in for it.
|
||
const run = c.run.length ? c.run : c.usableRows;
|
||
// Marks each measure lost per hour, averaged across the run so one bad hour
|
||
// in nine can't name the day. Per-hour weights (partWeights) so a severe
|
||
// gust costs here exactly what it cost in hourQuality.
|
||
const lostSum = {};
|
||
let hours = 0;
|
||
for (const r of run) {
|
||
const p = hourParts(r, crit);
|
||
const w = partWeights(r, crit);
|
||
const keys = Object.keys(p).filter(k => p[k] != null && w[k]);
|
||
const wsum = keys.reduce((s, k) => s + w[k], 0);
|
||
if (!wsum) continue;
|
||
hours++;
|
||
for (const k of keys) lostSum[k] = (lostSum[k] ?? 0) + (w[k] / wsum) * (1 - p[k]);
|
||
}
|
||
|
||
// Marks lost, in points of the final score: quality is half of it, split
|
||
// between the measures by weight; window length is the other half.
|
||
const lost = [['short', 0.5 * (1 - span)]];
|
||
for (const k of Object.keys(lostSum)) lost.push([k, 0.5 * lostSum[k] / hours]);
|
||
lost.sort((a, b) => b[1] - a[1]);
|
||
// Thermal has two opposite causes and they want opposite icons - a cold
|
||
// morning and a scorching afternoon are not the same warning.
|
||
const meanT = run.reduce((s, r) => s + (r[crit.field] ?? 0), 0) / run.length;
|
||
const thermalIcon = meanT > crit.pl.flatHi ? SHORTFALL_ICON.hot : SHORTFALL_ICON.cold;
|
||
|
||
// Turn each measure's lost marks into a named influence. Length lost names
|
||
// what ruled the other hours out; if they can't say it is dropped, so the
|
||
// list only ever holds weather influences.
|
||
const byName = {};
|
||
for (const [k, amount] of lost) {
|
||
if (amount < SHORTFALL_FLOOR) continue;
|
||
const f = k === 'short' ? shortBecause(c, crit)
|
||
: k === 'thermal' ? thermalIcon
|
||
: SHORTFALL_ICON[k];
|
||
if (!f) continue;
|
||
// Length blamed on wind and wind inside the window are one influence.
|
||
byName[f.name] = byName[f.name]
|
||
? { ...byName[f.name], amount: byName[f.name].amount + amount }
|
||
: { ...f, amount };
|
||
}
|
||
|
||
// Top three by marks lost, worst first - the first is the row's icon.
|
||
const top = Object.values(byName)
|
||
.sort((a, b) => b.amount - a.amount)
|
||
.slice(0, 3)
|
||
.map(({ amount, ...f }) => ({ ...f, cost: pts(amount) }));
|
||
return top.length ? top : [SHORTFALL_ICON.none];
|
||
}
|
||
|
||
export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
|
||
if (!weekDays || weekDays.length === 0) return [];
|
||
const crit = bestDayCriteria(profile, variant);
|
||
// Round-the-clock profiles drop the waking-hours limit.
|
||
if (isAllDay(profile, variant)) crit.hours = null;
|
||
// Sun-seeking leisure variants trade some thermal weight for sunshine.
|
||
if (profile === 'outdoors' && SUNNY_VARIANTS.includes(variant)) {
|
||
crit.w = { ...crit.w, thermal: crit.w.thermal - SUN_WEIGHT, sun: SUN_WEIGHT };
|
||
}
|
||
|
||
const candidates = [];
|
||
for (const day of weekDays.slice(0, 7)) {
|
||
if (!day?.rows?.length) continue;
|
||
// Drop hours that have already passed - only ever bites on day 0.
|
||
const rows = nowLocalISO
|
||
? day.rows.filter(r => r.iso.slice(0, 13) >= nowLocalISO)
|
||
: day.rows;
|
||
|
||
let start = -1, len = 0, dayBest = null;
|
||
for (let i = 0; i < rows.length; i++) {
|
||
if (comfortableHour(rows[i], crit)) {
|
||
if (start < 0) start = i;
|
||
len++;
|
||
if (!dayBest || len > dayBest.len) dayBest = { start, len, end: i };
|
||
} else {
|
||
start = -1; len = 0;
|
||
}
|
||
}
|
||
// Hours the day is judged against (daylight only for outdoor profiles,
|
||
// waking hours for town walking).
|
||
const usableRows = (crit.daylightOnly ? rows.filter(r => r.elev > 0) : rows)
|
||
.filter(r => inHours(r, crit));
|
||
// Nothing left to judge (e.g. today after dark) - no honest score exists.
|
||
if (usableRows.length === 0) continue;
|
||
|
||
// A day with no window of PICK_MIN_HOURS is still shown, with a low score
|
||
// and the reason it failed - hiding it made the week look shorter than it is.
|
||
const hasWindow = dayBest && dayBest.len >= PICK_MIN_HOURS;
|
||
|
||
// Average quality across the run. Length alone can't separate days: in a
|
||
// temperate summer week half of them qualify dawn to dusk, and picking
|
||
// arbitrarily among those is a coin toss. With no window the whole
|
||
// usable day is averaged instead.
|
||
const run = hasWindow ? rows.slice(dayBest.start, dayBest.end + 1) : [];
|
||
const qRows = hasWindow ? run : usableRows;
|
||
const quality = qRows.reduce((s, r) => s + hourQuality(r, crit), 0) / qRows.length;
|
||
|
||
candidates.push({
|
||
len: hasWindow ? dayBest.len : 0, quality, key: day.key, run, hasWindow,
|
||
from: hasWindow ? rows[dayBest.start] : null,
|
||
to: hasWindow ? rows[dayBest.end] : null,
|
||
// What the window is measured AGAINST - and, for the hours outside the
|
||
// run, the evidence for WHY it stopped where it did: the daylight for a
|
||
// profile that
|
||
// only counts daylight hours, otherwise the whole day. Using daylight
|
||
// for a round-the-clock profile would let a 14-hour overnight window
|
||
// score over 100% of a 16-hour day and print "all day" for a spell that
|
||
// ends at breakfast.
|
||
usableRows,
|
||
});
|
||
}
|
||
// No day left anything to judge (e.g. only dark hours remain) - an empty
|
||
// panel reads as a bug, so say so.
|
||
if (candidates.length === 0) {
|
||
return [{
|
||
icon: crit.field === 'utciAdj' ? '🌧️' : '🚫',
|
||
label: 'Next 7 days',
|
||
value: EMPTY_VALUE[crit.field] ?? EMPTY_VALUE.utciAdj,
|
||
alert: false,
|
||
}];
|
||
}
|
||
|
||
// Score each day out of 100 against an ideal day out rather than against
|
||
// the rest of the week: a middling week should read as middling, not have
|
||
// its least-bad day flattered up to full marks.
|
||
//
|
||
// how much of the daylight is usable (50%)
|
||
// where in the bands those hours actually sit (50%)
|
||
//
|
||
// Even between the two: a long window of merely tolerable hours and a short
|
||
// window of perfect ones are both worth knowing about, and letting either
|
||
// half dominate hides one of them.
|
||
for (const c of candidates) {
|
||
const span = c.usableRows.length > 0
|
||
? Math.min(c.len / c.usableRows.length, 1)
|
||
: 0;
|
||
c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality));
|
||
// Same span the score used, so the icon can never blame the weather for
|
||
// marks that were actually lost to a short window.
|
||
c.causes = limitingFactors(c, crit, span);
|
||
c.cause = c.causes[0];
|
||
}
|
||
|
||
// A settled week really can have seven good days, and cutting it to a
|
||
// shortlist there would say the opposite of what the forecast shows. Every
|
||
// day of the week gets a row - poor days with a low bar and their reason -
|
||
// so the cap is the week itself, not an arbitrary four.
|
||
const shown = [...candidates]
|
||
.sort((a, b) => b.score - a.score)
|
||
.slice(0, PICK_MAX_DAYS)
|
||
.sort((a, b) => (a.key < b.key ? -1 : 1));
|
||
|
||
const hh = (iso) => {
|
||
const h = parseInt(iso.slice(11, 13), 10);
|
||
return `${h % 12 || 12}${h < 12 ? 'am' : 'pm'}`;
|
||
};
|
||
|
||
return shown.map(c => {
|
||
// Dates are keyed 'YYYY-MM-DD' in local wall-clock terms, so read them back
|
||
// as UTC to stop the browser's own zone shifting the weekday.
|
||
const dayName = new Date(`${c.key}T00:00:00Z`)
|
||
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', timeZone: 'UTC' });
|
||
|
||
// The end row is the last comfortable hour, so the window runs to the end
|
||
// of it - 9am-12pm means 9:00 up to 12:59.
|
||
// c.to is null on a day with no window, which has no end time to build.
|
||
const endIso = c.to
|
||
? `${c.to.iso.slice(0, 11)}${String((parseInt(c.to.iso.slice(11, 13), 10) + 1) % 24).padStart(2, '0')}:00`
|
||
: null;
|
||
|
||
// A run spanning nearly all the daylight is better said than shown: printing
|
||
// "6am - 9pm" makes the reader parse a time range to learn "all day".
|
||
// A day with no window shows its reason in words instead of a time range.
|
||
// Kept to the cause name alone: anything longer than "10am – 4pm" wraps
|
||
// onto a second line in the narrow rail.
|
||
const window = !c.hasWindow
|
||
? c.cause.name
|
||
: c.len >= c.usableRows.length - 1
|
||
? 'all day'
|
||
: `${hh(c.from.iso)} – ${hh(endIso)}`;
|
||
|
||
return {
|
||
// Not a calendar - the row already says which day. The icon carries the
|
||
// one thing the score cannot: what is holding the day back.
|
||
icon: c.cause.icon,
|
||
iconTitle: causeTip(c.cause),
|
||
// The top three influences for the row's popup, worst first.
|
||
factors: c.causes.map(f => ({ icon: f.icon, text: causeTip(f) })),
|
||
label: dayName,
|
||
value: window,
|
||
// Rows stay in date order - the order you plan in - so the ranking is
|
||
// carried by the score bar instead of by position.
|
||
score: c.score,
|
||
alert: false,
|
||
// No grp: the group tints categorise the glance rail's mixed subjects,
|
||
// but here every row is the same kind of thing, so a colour would be
|
||
// decoration. Plain rows with hairline dividers instead.
|
||
};
|
||
});
|
||
}
|
||
|
||
// Header line for the panel: names what is being judged when a variant makes
|
||
// that non-obvious, otherwise the plain framing.
|
||
export function bestDaysLabel(profile, variant) {
|
||
const { mainField, mainLabel } = deriveProfileMain(profile, variant);
|
||
// The vehicle and home profiles have no variant to name, but "best times
|
||
// out" would be plainly wrong for them - the panel is now reading the cabin
|
||
// and the room, not the pavement.
|
||
if (profile === 'vehicle') return 'Best times to drive';
|
||
if (profile === 'home') return 'Best times indoors';
|
||
if (profile === 'pets') return 'Best times for pets';
|
||
return profile === 'outdoors' && variant
|
||
? `Best times for ${mainLabel}`
|
||
: (mainField === 'utciAdj' ? 'Best times out' : `Best times for ${mainLabel}`);
|
||
}
|
||
|
||
// Tooltip on the score bar. It has to name the field being scored, or a
|
||
// driver reads "temperatures nearer 20 °C" against the outside air and
|
||
// concludes the panel is broken.
|
||
export function bestDaysHint(profile, variant, score) {
|
||
const { mainField } = deriveProfileMain(profile, variant);
|
||
const what = {
|
||
vehicleT: 'an ideal driving day — longer stretches with a comfortable cabin score higher',
|
||
indoorT: 'an ideal day indoors — longer stretches with the building in comfortable range score higher',
|
||
furSurfaceT: 'an ideal day for a pet outside — longer stretches with fur and ground temperatures in safe range score higher',
|
||
}[mainField] ?? 'an ideal day out — longer comfortable stretches and temperatures nearer 20 °C score higher';
|
||
return `${score}% of ${what}`;
|
||
}
|
||
|
||
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);
|
||
};
|
||
|
||
// ── 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' }),
|
||
};
|
||
});
|
||
|
||
// ── 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(', ');
|
||
|
||
// 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 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) {
|
||
// 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;
|
||
} |